Skip to content
Open
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
4 changes: 0 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
<!-- 작업이 완료된 이슈의 번호를 기록해주세요. -->


## 리뷰/머지 희망 기한 (선택)
<!-- 해당 PR이 언제까지 리뷰되길 바라는지 작성해주세요 -->


## 작업내용
<!-- 작업한 내용을 설명해주세요. 왜 수정했는지 ? 무엇을 구현했는지 등등 -->

Expand Down
23 changes: 22 additions & 1 deletion core/database/src/main/java/com/twix/database/TwixDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@ package com.twix.database

import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.twix.database.poke.PokeHistoryDao
import com.twix.database.poke.PokeHistoryEntity

@Database(
entities = [PokeHistoryEntity::class],
version = 1,
version = 2,
)
abstract class TwixDatabase : RoomDatabase() {
abstract fun pokeHistoryDao(): PokeHistoryDao

companion object {
val MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS poke_history")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS poke_history (
goalId INTEGER NOT NULL,
targetDate TEXT NOT NULL,
pokedAt INTEGER NOT NULL,
PRIMARY KEY(goalId, targetDate)
)
""".trimIndent(),
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ val databaseModule =
androidContext(),
TwixDatabase::class.java,
"twix-database",
).build()
).addMigrations(TwixDatabase.MIGRATION_1_2)
.build()
}
single { get<TwixDatabase>().pokeHistoryDao() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ interface PokeHistoryDao {
@Upsert
suspend fun upsert(entity: PokeHistoryEntity)

@Query("SELECT * FROM poke_history WHERE goalId = :goalId")
suspend fun findByGoalId(goalId: Long): PokeHistoryEntity?
@Query("SELECT * FROM poke_history WHERE goalId = :goalId AND targetDate = :targetDate")
suspend fun findPokeHistoryEntity(
goalId: Long,
targetDate: String,
): PokeHistoryEntity?
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.twix.database.poke

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "poke_history")
@Entity(
tableName = "poke_history",
primaryKeys = ["goalId", "targetDate"],
)
data class PokeHistoryEntity(
@PrimaryKey val goalId: Long,
val goalId: Long,
val targetDate: String,
val pokedAt: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ class DefaultPokeRepository(

override suspend fun savePokeHistory(
goalId: Long,
targetDate: String,
pokedAt: Long,
) {
pokeHistoryDao.upsert(PokeHistoryEntity(goalId = goalId, pokedAt = pokedAt))
pokeHistoryDao.upsert(
PokeHistoryEntity(
goalId = goalId,
targetDate = targetDate,
pokedAt = pokedAt,
),
)
}

override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistoryDao.findByGoalId(goalId)?.pokedAt
override suspend fun findPokeHistory(
goalId: Long,
targetDate: String,
): Long? = pokeHistoryDao.findPokeHistoryEntity(goalId, targetDate)?.pokedAt
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ interface PokeRepository {

suspend fun savePokeHistory(
goalId: Long,
targetDate: String,
pokedAt: Long,
)

suspend fun findPokeHistory(goalId: Long): Long?
suspend fun findPokeHistory(
goalId: Long,
targetDate: String,
): Long?
}
16 changes: 11 additions & 5 deletions domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,27 @@ import com.twix.result.AppResult
class PokeGoalUseCase(
private val pokeRepository: PokeRepository,
) {
suspend fun invoke(goalId: Long): PokeGoalResult {
val remainingMs = remainingCooldown(goalId)
suspend fun invoke(
goalId: Long,
targetDate: String,
): PokeGoalResult {
val remainingMs = remainingCooldown(goalId, targetDate)
if (remainingMs > 0) return PokeGoalResult.OnCooldown(remainingMs)

return when (val result = pokeRepository.pokeGoal(goalId)) {
is AppResult.Success -> {
pokeRepository.savePokeHistory(goalId, System.currentTimeMillis())
pokeRepository.savePokeHistory(goalId, targetDate, System.currentTimeMillis())
PokeGoalResult.Success(result.data.message)
}
is AppResult.Error -> PokeGoalResult.Error
}
}

suspend fun remainingCooldown(goalId: Long): Long {
val pokedAt = pokeRepository.findPokeHistory(goalId) ?: return 0L
suspend fun remainingCooldown(
goalId: Long,
targetDate: String,
): Long {
val pokedAt = pokeRepository.findPokeHistory(goalId, targetDate) ?: return 0L
val currentTime = System.currentTimeMillis()
val elapsedMs = currentTime - pokedAt
val remainingMs = COOLDOWN_MS - elapsedMs
Expand Down
17 changes: 13 additions & 4 deletions domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import com.twix.domain.repository.PokeRepository
import com.twix.result.AppResult

class FakePokeRepository : PokeRepository {
val pokeHistory: MutableMap<Long, Long?> = mutableMapOf()
val savedPokeHistory: MutableMap<Long, Long> = mutableMapOf()
val pokeHistory: MutableMap<PokeHistoryKey, Long?> = mutableMapOf()
val savedPokeHistory: MutableMap<PokeHistoryKey, Long> = mutableMapOf()
var pokeGoalResult: AppResult<PokeResult> = AppResult.Success(PokeResult(message = ""))
var pokeGoalCallCount: Int = 0

Expand All @@ -17,10 +17,19 @@ class FakePokeRepository : PokeRepository {

override suspend fun savePokeHistory(
goalId: Long,
targetDate: String,
pokedAt: Long,
) {
savedPokeHistory[goalId] = pokedAt
savedPokeHistory[PokeHistoryKey(goalId, targetDate)] = pokedAt
}

override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistory[goalId]
override suspend fun findPokeHistory(
goalId: Long,
targetDate: String,
): Long? = pokeHistory[PokeHistoryKey(goalId, targetDate)]

data class PokeHistoryKey(
val goalId: Long,
val targetDate: String,
)
}
84 changes: 68 additions & 16 deletions domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,45 +25,50 @@ class PokeGoalUseCaseTest {
runTest {
// given
val goalId = 1L
val targetDate = "2026-06-07"
val serverMessage = "서버 응답 메시지"
fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage))
fakePokeRepository.pokeHistory[goalId] = null
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null

// when
val result = useCase.invoke(goalId)
val result = useCase.invoke(goalId, targetDate)

// then
assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java)
assertThat((result as PokeGoalResult.Success).message).isEqualTo(serverMessage)
assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNotNull()
assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)])
.isNotNull()
}

@Test
fun `쿨타임이 없고 찌르기가 실패하면 Error를 반환하고 히스토리가 저장되지 않는다`() =
runTest {
// given
val goalId = 2L
val targetDate = "2026-06-07"
fakePokeRepository.pokeGoalResult = AppResult.Error(AppError.Network())
fakePokeRepository.pokeHistory[goalId] = null
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null

// when
val result = useCase.invoke(goalId)
val result = useCase.invoke(goalId, targetDate)

// then
assertThat(result).isEqualTo(PokeGoalResult.Error)
assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNull()
assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)])
.isNull()
}

@Test
fun `쿨타임 중이면 OnCooldown을 반환하고 pokeGoal이 호출되지 않는다`() =
runTest {
// given
val goalId = 3L
val targetDate = "2026-06-07"
val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2)
fakePokeRepository.pokeHistory[goalId] = recentPokedAt
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt

// when
val result = useCase.invoke(goalId)
val result = useCase.invoke(goalId, targetDate)

// then
assertThat(result).isInstanceOf(PokeGoalResult.OnCooldown::class.java)
Expand All @@ -76,13 +81,14 @@ class PokeGoalUseCaseTest {
runTest {
// given
val goalId = 4L
val targetDate = "2026-06-07"
val justExpiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 1
val serverMessage = "서버 응답 메시지"
fakePokeRepository.pokeHistory[goalId] = justExpiredPokedAt
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = justExpiredPokedAt
fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage))

// when
val result = useCase.invoke(goalId)
val result = useCase.invoke(goalId, targetDate)

// then
assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java)
Expand All @@ -94,10 +100,11 @@ class PokeGoalUseCaseTest {
runTest {
// given
val goalId = 10L
fakePokeRepository.pokeHistory[goalId] = null
val targetDate = "2026-06-07"
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null

// when
val remaining = useCase.remainingCooldown(goalId)
val remaining = useCase.remainingCooldown(goalId, targetDate)

// then
assertThat(remaining).isEqualTo(0L)
Expand All @@ -108,11 +115,12 @@ class PokeGoalUseCaseTest {
runTest {
// given
val goalId = 11L
val targetDate = "2026-06-07"
val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2)
fakePokeRepository.pokeHistory[goalId] = recentPokedAt
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt

// when
val remaining = useCase.remainingCooldown(goalId)
val remaining = useCase.remainingCooldown(goalId, targetDate)

// then
assertThat(remaining).isGreaterThan(0L)
Expand All @@ -123,13 +131,57 @@ class PokeGoalUseCaseTest {
runTest {
// given
val goalId = 12L
val targetDate = "2026-06-07"
val expiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 100
fakePokeRepository.pokeHistory[goalId] = expiredPokedAt
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = expiredPokedAt

// when
val remaining = useCase.remainingCooldown(goalId)
val remaining = useCase.remainingCooldown(goalId, targetDate)

// then
assertThat(remaining).isEqualTo(0L)
}

@Test
fun `같은 목표라도 날짜가 다르면 remainingCooldown은 독립적으로 계산된다`() =
runTest {
// given
val goalId = 20L
val pokedDate = "2026-06-07"
val otherDate = "2026-06-08"
val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2)
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt

// when
val pokedDateRemaining = useCase.remainingCooldown(goalId, pokedDate)
val otherDateRemaining = useCase.remainingCooldown(goalId, otherDate)

// then
assertThat(pokedDateRemaining).isGreaterThan(0L)
assertThat(otherDateRemaining).isEqualTo(0L)
}

@Test
fun `같은 목표의 다른 날짜 쿨타임은 찌르기 요청을 막지 않는다`() =
runTest {
// given
val goalId = 21L
val pokedDate = "2026-06-07"
val otherDate = "2026-06-08"
val serverMessage = "서버 응답 메시지"
val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2)
fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt
fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage))

// when
val result = useCase.invoke(goalId, otherDate)

// then
assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java)
assertThat(fakePokeRepository.pokeGoalCallCount).isEqualTo(1)
assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, otherDate)])
.isNotNull()
assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)])
.isNull()
}
}
2 changes: 1 addition & 1 deletion feature/main/src/main/java/com/twix/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class HomeViewModel(

private fun pokeGoal(goalId: Long) {
viewModelScope.launch {
when (val result = pokeGoalUseCase.invoke(goalId)) {
when (val result = pokeGoalUseCase.invoke(goalId, currentState.selectedDate.toString())) {
is PokeGoalResult.Success ->
tryEmitSideEffect(HomeSideEffect.ShowPokeToast)
is PokeGoalResult.OnCooldown ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ fun PhotologDetailScreen(

PhotologCardContent(
uiState = uiState,
isPokeDisabled = uiState.isPokeDisabled,
onSwipe = onSwipe,
onClickUpload = onClickUpload,
onPoke = onPoke,
Expand Down
Loading
Loading