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
Expand Up @@ -41,8 +41,10 @@ enum class Recurrence(
/**
* 되풀이하는 일정의 회차를 세는 자리.
*
* 기준 시각([baseMillis])은 첫 회차다. 회차의 시·분·초는 첫 회차와 같고 날짜만 규칙을 따른다.
* 계산은 기기의 현지 달력으로 한다 — 사용자가 「아침 8시」 라고 정한 것은 현지시각 8시다.
* 기준 시각은 저장된 첫 회차의 epoch 다. 이를 계산 당시 기기 시간대로 해석한 시·분·초와
* 요일·일자를 반복한다. 생성 당시 시간대나 벽시계 시각은 따로 저장하지 않는다. 따라서 시간대가
* 바뀌어도 생성 당시의 「8시」를 유지한다는 뜻은 아니다. 기존 epoch 와 회차 키는 이행하지 않는다.
* DST 중복·공백 시각은 Calendar 의 기본 해석을 따른다. 세부 경계는 docs/recurrence-time-policy.md.
*/
object RecurrenceRule {
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.example.slowclock.util

import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.time.OffsetDateTime
import java.util.TimeZone

/** 기존 epoch 모델의 경계. 실행기 전체 기본값을 바꾸므로 매 테스트 뒤 원래 시간대로 돌린다. */
class RecurrenceTimeZoneTest {
private lateinit var originalZone: TimeZone

@Before
fun saveTimeZone() {
originalZone = TimeZone.getDefault()
}

@After
fun restoreTimeZone() {
TimeZone.setDefault(originalZone)
}

@Test
fun `서울에서 만든 epoch는 LA 계산 시 LA 시각과 요일로 해석한다`() {
val base = instant("2026-01-06T08:00:00+09:00")
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))

assertEquals(
instant("2026-01-08T15:00:00-08:00"),
RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-01-08T00:00:00-08:00")),
)
assertEquals(
instant("2026-01-12T15:00:00-08:00"),
RecurrenceRule.occurrenceOn(base, Recurrence.WEEKLY, instant("2026-01-12T00:00:00-08:00")),
)
assertNull(RecurrenceRule.occurrenceOn(base, Recurrence.WEEKLY, instant("2026-01-13T00:00:00-08:00")))
}

@Test
fun `단발 일정의 epoch는 시간대 변경 뒤에도 그대로다`() {
val base = instant("2026-01-06T08:00:00+09:00")
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))

assertEquals(base, RecurrenceRule.occurrenceOn(base, Recurrence.NONE, instant("2026-01-05T00:00:00-08:00")))
assertEquals(base, RecurrenceRule.nextOccurrenceAfter(base, Recurrence.NONE, base - 1))
assertNull(RecurrenceRule.nextOccurrenceAfter(base, Recurrence.NONE, base))
}

@Test
fun `동일 시간대의 매일 아침 회차는 DST 시작 때 23시간 뒤다`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
val base = instant("2026-02-01T08:00:00-08:00")
val before = instant("2026-03-07T08:00:00-08:00")
val after = RecurrenceRule.nextOccurrenceAfter(base, Recurrence.DAILY, before)

assertEquals(instant("2026-03-08T08:00:00-07:00"), after)
assertEquals(23 * 60 * 60 * 1_000L, after!! - before)
}

@Test
fun `호스트 Calendar의 DST 공백은 존재하는 이후 시각으로 보정한다`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
val base = instant("2026-02-01T02:30:00-08:00")

assertEquals(
instant("2026-03-08T03:30:00-07:00"),
RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-03-08T00:00:00-08:00")),
)
}

@Test
fun `호스트 Calendar의 DST 중복은 늦은 offset의 회차 하나를 선택한다`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
val base = instant("2026-10-01T01:30:00-07:00")
val occurrence = RecurrenceRule.occurrenceOn(base, Recurrence.DAILY, instant("2026-11-01T00:00:00-07:00"))

assertEquals(instant("2026-11-01T01:30:00-08:00"), occurrence)
assertEquals(
instant("2026-11-02T01:30:00-08:00"),
RecurrenceRule.nextOccurrenceAfter(base, Recurrence.DAILY, occurrence!!),
)
}

@Test
fun `회차 키는 계산 당시 시간대의 날짜며 기존 키를 이행하지 않는다`() {
val base = instant("2026-01-06T08:00:00+09:00")
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
assertEquals("2026-01-06", RecurrenceRule.occurrenceKey(base))
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
assertEquals("2026-01-05", RecurrenceRule.occurrenceKey(base))
}

private fun instant(value: String): Long = OffsetDateTime.parse(value).toInstant().toEpochMilli()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.example.slowclock.data.FirestoreCollections
import com.google.firebase.Timestamp
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.tasks.await
import javax.inject.Inject

Expand Down Expand Up @@ -43,6 +44,8 @@ class FamilyGroupRepository
).await()
}
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.example.slowclock.data.remote.repository

import com.example.slowclock.data.FirestoreCollections
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.tasks.await
import javax.inject.Inject

Expand Down Expand Up @@ -29,6 +30,8 @@ class NotificationRepository
batch.commit().await()
}
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,6 @@ class ScheduleRepository
return ScheduleResult.Error(AppError.AuthError)
}

val startOfDay = calendar.clone() as Calendar
startOfDay.set(Calendar.HOUR_OF_DAY, 0)
startOfDay.set(Calendar.MINUTE, 0)
startOfDay.set(Calendar.SECOND, 0)
startOfDay.set(Calendar.MILLISECOND, 0)

val endOfDay = calendar.clone() as Calendar
endOfDay.set(Calendar.HOUR_OF_DAY, 23)
endOfDay.set(Calendar.MINUTE, 59)
endOfDay.set(Calendar.SECOND, 59)
endOfDay.set(Calendar.MILLISECOND, 999)

return try {
val documents =
schedulesCollection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class UserRepository
return try {
val document = usersCollection.document(uid).get().await()
document.toObject<User>()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
Expand Down Expand Up @@ -123,6 +125,8 @@ class UserRepository
}.commit()
.await()
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "사용자 문서 삭제 실패", e)
false
Expand Down Expand Up @@ -167,6 +171,8 @@ class UserRepository
savePublicProfile(uid, if (name.isNotBlank()) name else existing.name)
}
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의
// 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다.
Expand All @@ -188,6 +194,8 @@ class UserRepository
.document(uid)
.set(PublicProfile(id = uid, name = name))
.await()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "공개 프로필 저장 실패", e)
}
Expand Down Expand Up @@ -220,6 +228,8 @@ class UserRepository
.filter { (_, name) -> name.isNotBlank() }
.toMap()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "공유 일정 소유자 이름 조회 실패", e)
emptyMap()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.example.slowclock.data.remote.repository

import com.google.android.gms.tasks.TaskCompletionSource
import com.google.android.gms.tasks.Tasks
import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import com.google.firebase.firestore.QuerySnapshot
import com.google.firebase.firestore.WriteBatch
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Test

class AccountCleanupCancellationTest {
private val firestore = mockk<FirebaseFirestore>()
private val collection = mockk<CollectionReference>()
private val owned = mockk<Query>()
private val joined = mockk<Query>()
private val snapshot = mockk<QuerySnapshot>()
private val document = mockk<DocumentSnapshot>()
private val reference = mockk<DocumentReference>()
private val batch = mockk<WriteBatch>()

init {
every { firestore.collection(any()) } returns collection
every { collection.whereEqualTo(any<String>(), any()) } returns owned
every { collection.whereArrayContains("memberIds", "owner") } returns joined
every { owned.get() } returns Tasks.forResult(snapshot)
every { joined.get() } returns Tasks.forResult(snapshot)
every { snapshot.documents } returns listOf(document)
every { document.reference } returns reference
every { reference.delete() } returns Tasks.forResult(null)
every { firestore.batch() } returns batch
every { batch.delete(reference) } returns batch
}

@Test
fun `알림 조회 취소는 삭제 batch를 시작하지 않는다`() =
runTest {
every { owned.get() } returns TaskCompletionSource<QuerySnapshot>().task
assertCancellationStops { NotificationRepository(firestore).deleteAllNotificationsOf("owner") }
verify(exactly = 0) { firestore.batch() }
}

@Test
fun `알림 삭제 commit 대기 취소는 실패 반환으로 바꾸지 않는다`() =
runTest {
every { batch.commit() } returns TaskCompletionSource<Void>().task
assertCancellationStops { NotificationRepository(firestore).deleteAllNotificationsOf("owner") }
}

@Test
fun `소유 그룹 조회 취소는 그룹을 삭제하지 않는다`() =
runTest {
every { owned.get() } returns TaskCompletionSource<QuerySnapshot>().task
assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") }
verify(exactly = 0) { reference.delete() }
}

@Test
fun `소유 그룹 삭제 대기 취소는 참여 그룹 조회를 시작하지 않는다`() =
runTest {
every { reference.delete() } returns TaskCompletionSource<Void>().task
assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") }
verify(exactly = 0) { joined.get() }
}

@Test
fun `참여 그룹 조회 취소는 실패 반환으로 바꾸지 않는다`() =
runTest {
every { joined.get() } returns TaskCompletionSource<QuerySnapshot>().task
assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") }
}

@Test
fun `참여 그룹 갱신 대기 취소는 실패 반환으로 바꾸지 않는다`() =
runTest {
every { reference.update(any<Map<String, Any>>()) } returns TaskCompletionSource<Void>().task
assertCancellationStops { FamilyGroupRepository(firestore).leaveAllGroupsOf("owner") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.slowclock.data.remote.repository

import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import org.junit.Assert.assertFalse

/** Job 의 isCancelled 만 보면 취소를 삼킨 함수도 통과한다. 호출 뒤 동기 코드가 실행됐는지 본다. */
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
internal suspend fun TestScope.assertCancellationStops(block: suspend () -> Any?) {
var returned = false
val job =
launch {
block()
returned = true
}
runCurrent()
assertFalse("실제 Task 응답을 기다려야 한다", job.isCompleted)
job.cancelAndJoin()
assertFalse("취소를 일반 반환으로 바꾸면 호출 뒤 코드가 실행된다", returned)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import org.junit.Test
class UserRepositoryShareCodeTest {
private val uid = "owner"
private val code = "ABC123"
private val auth = mockk<FirebaseAuth>()
private val firestore = mockk<FirebaseFirestore>()
private val users = mockk<CollectionReference>()
private val profiles = mockk<CollectionReference>()
Expand All @@ -40,6 +41,7 @@ class UserRepositoryShareCodeTest {
private val repository: UserRepository

init {
every { auth.currentUser } returns mockk { every { uid } returns this@UserRepositoryShareCodeTest.uid }
every { firestore.collection("users") } returns users
every { firestore.collection("publicProfiles") } returns profiles
every { firestore.collection("shareCodes") } returns registry
Expand All @@ -56,7 +58,7 @@ class UserRepositoryShareCodeTest {
every { firestore.batch() } returns batch
every { batch.delete(any()) } returns batch
every { batch.commit() } returns Tasks.forResult(null)
repository = UserRepository(mockk<FirebaseAuth>(), firestore, mockk<FirebaseMessaging>())
repository = UserRepository(auth, firestore, mockk<FirebaseMessaging>())
}

@Test
Expand Down Expand Up @@ -166,5 +168,50 @@ class UserRepositoryShareCodeTest {
verify(exactly = 0) { batch.commit() }
}

@Test
fun `현재 사용자 읽기 취소는 문서 없음으로 반환하지 않는다`() =
runTest {
every { userRef.get() } returns TaskCompletionSource<DocumentSnapshot>().task
assertCancellationStops { repository.getCurrentUser() }
}

@Test
fun `계정 삭제 읽기 취소는 batch를 시작하지 않는다`() =
runTest {
every { userRef.get() } returns TaskCompletionSource<DocumentSnapshot>().task
assertCancellationStops { repository.deleteUserDocument(uid) }
verify(exactly = 0) { firestore.batch() }
}

@Test
fun `계정 삭제 commit 대기 취소는 실패 결과로 바꾸지 않는다`() =
runTest {
every { batch.commit() } returns TaskCompletionSource<Void>().task
assertCancellationStops { repository.deleteUserDocument(uid) }
}

@Test
fun `코드 등록 대기 취소는 사용자 쓰기로 이어지지 않는다`() =
runTest {
every { codeRef.set(any()) } returns TaskCompletionSource<Void>().task
assertCancellationStops { repository.ensureShareCode(uid, "이름", "email@example.com") }
verify(exactly = 0) { userRef.update(any<Map<String, Any>>()) }
verify(exactly = 0) { profileRef.set(any()) }
}

@Test
fun `공개 프로필 저장 대기 취소도 ensureShareCode 성공으로 반환하지 않는다`() =
runTest {
every { profileRef.set(any()) } returns TaskCompletionSource<Void>().task
assertCancellationStops { repository.ensureShareCode(uid, "이름", "email@example.com") }
}

@Test
fun `공유 소유자 이름 읽기 취소는 빈 이름 목록으로 반환하지 않는다`() =
runTest {
every { profileRef.get() } returns TaskCompletionSource<DocumentSnapshot>().task
assertCancellationStops { repository.getUserNames(listOf(uid)) }
}

private fun denied() = FirebaseFirestoreException("denied", FirebaseFirestoreException.Code.PERMISSION_DENIED)
}
Loading
Loading