diff --git a/.github/scripts/functions-notification.test.mjs b/.github/scripts/functions-notification.test.mjs index d54526c0..189ae659 100644 --- a/.github/scripts/functions-notification.test.mjs +++ b/.github/scripts/functions-notification.test.mjs @@ -5,7 +5,7 @@ import vm from "node:vm"; const source = await readFile(new URL("../../functions/index.js", import.meta.url), "utf8"); -function fixture({ tokens = ["private-token"], sendError, readError, failed = 0 } = {}) { +function fixture({ watchers = [{ id: "recipient-uid", token: "private-token" }], sendError, readError, failed = 0 } = {}) { const messages = []; const logs = []; const codes = []; @@ -20,15 +20,15 @@ function fixture({ tokens = ["private-token"], sendError, readError, failed = 0 assert.equal(nested, "tokens"); return { get: async () => { if (readError) throw readError; - return { forEach: (visit) => tokens.forEach((fcmToken) => visit({ data: () => ({ fcmToken }) })) }; + return { forEach: (visit) => watchers.forEach(({ id, token, userId }) => visit({ id, data: () => ({ fcmToken: token, userId }) })) }; } }; } }; } }; } }), - messaging: () => ({ sendEachForMulticast: async (message) => { + messaging: () => ({ sendEach: async (message) => { messages.push(JSON.parse(JSON.stringify(message))); if (sendError) throw sendError; - return { successCount: message.tokens.length - failed, failureCount: failed }; + return { successCount: message.length - failed, failureCount: failed }; } }), }; vm.runInNewContext(source, { @@ -63,7 +63,11 @@ for (const [name, before, after, title] of [ const f = fixture(); await f.handle(change(before, after)); assert.deepEqual(f.codes, ["ABC123"]); - assert.deepEqual(f.messages, [{ notification: { title, body: (after || before).title }, tokens: ["private-token"] }]); + assert.deepEqual(f.messages, [[{ + token: "private-token", + data: { type: "shared_schedule", schemaVersion: "1", recipientUid: "recipient-uid", shareCode: "ABC123", scheduleId: "schedule-id", title, body: (after || before).title }, + android: { priority: "high" }, + }]]); const logged = JSON.stringify(f.logs); assert.doesNotMatch(logged, /private-token|private schedule|updated title|ABC123/); }); @@ -76,23 +80,24 @@ test("missing event, unshared schedule and no recipients do not send", async () assert.equal(f.messages.length, 0); assert.equal(f.codes.length, 0); } - const f = fixture({ tokens: [undefined, "", " ", 42] }); + const f = fixture({ watchers: [undefined, "", " ", 42].map((token) => ({ id: "recipient", token })) }); assert.equal(await f.handle(change(undefined, schedule)), null); assert.equal(f.messages.length, 0); }); -test("deduplicates registration tokens and splits at the real multicast limit", async () => { +test("deduplicates UID-token pairs and splits at the real sendEach limit", async () => { const tokens = Array.from({ length: 1001 }, (_, index) => `token-${index}`); - const f = fixture({ tokens: [...tokens, tokens[0], tokens[1000]] }); + const watchers = tokens.map((token, index) => ({ id: `uid-${index}`, token })); + const f = fixture({ watchers: [...watchers, watchers[0], watchers[1000]] }); const result = await f.handle(change(undefined, schedule)); - assert.deepEqual(f.messages.map((message) => message.tokens.length), [500, 500, 1]); - assert.deepEqual(f.messages.flatMap((message) => message.tokens), tokens); + assert.deepEqual(f.messages.map((message) => message.length), [500, 500, 1]); + assert.deepEqual(f.messages.flat().map((message) => message.token), tokens); assert.equal(result.successCount, 1001); assert.equal(result.failureCount, 0); }); test("records partial delivery counts without exposing token or payload", async () => { - const f = fixture({ tokens: ["one", "two"], failed: 1 }); + const f = fixture({ watchers: ["one", "two"].map((token) => ({ id: "uid", token })), failed: 1 }); const result = await f.handle(change(undefined, schedule)); assert.equal(result.successCount, 1); assert.equal(result.failureCount, 1); @@ -106,3 +111,29 @@ test("Firestore and total FCM failures reject instead of claiming success", asyn assert.equal(f.logs.length, 0); } }); + + +test("same token under two UIDs preserves both recipients and ignores forged userId fields", async () => { + const f = fixture({ watchers: [ + { id: "old-uid", token: "same-token", userId: "new-uid" }, + { id: "new-uid", token: "same-token", userId: "old-uid" }, + { id: "new-uid", token: "same-token" }, + ] }); + await f.handle(change(undefined, schedule)); + assert.deepEqual(f.messages.flat().map(({ data }) => data.recipientUid), ["old-uid", "new-uid"]); + assert.equal(f.messages.flat().length, 2); +}); + +test("missing schedule ID, invalid code or invalid watcher UID fails closed", async () => { + for (const event of [ + { ...change(undefined, schedule), params: {} }, + change(undefined, { ...schedule, sharedCode: " " }), + change(undefined, { ...schedule, sharedCode: 123 }), + ]) { + const f = fixture(); + assert.equal(await f.handle(event), null); + assert.equal(f.messages.length, 0); + } + const f = fixture({ watchers: [undefined, "", " ", 7].map((id) => ({ id, token: "valid" })) }); + assert.equal(await f.handle(change(undefined, schedule)), null); +}); diff --git a/app/src/androidTest/java/com/example/slowclock/notification/SharedScheduleNotificationTest.kt b/app/src/androidTest/java/com/example/slowclock/notification/SharedScheduleNotificationTest.kt new file mode 100644 index 00000000..2b11f668 --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/notification/SharedScheduleNotificationTest.kt @@ -0,0 +1,160 @@ +package com.example.slowclock.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.ContextWrapper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import com.google.android.gms.tasks.Tasks +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +/** Opt in on a disposable emulator with -e sharedAuthEmulatorPort 9096. Never connects to production Auth. */ +@RunWith(AndroidJUnit4::class) +class SharedScheduleNotificationTest { + private lateinit var firebaseApp: FirebaseApp + private lateinit var auth: AuthRepository + private lateinit var settings: SettingsRepository + private lateinit var notifier: SharedScheduleNotifier + private lateinit var manager: NotificationManager + private lateinit var context: Context + private lateinit var message: SharedScheduleMessage + + @Before + fun setUp() { + val port = InstrumentationRegistry.getArguments().getString("sharedAuthEmulatorPort")?.toIntOrNull() + assumeTrue("Requires an explicit local Auth emulator fixture", port != null) + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + context = + object : ContextWrapper(targetContext) { + override fun getSharedPreferences( + name: String, + mode: Int, + ) = super.getSharedPreferences("fcm_fixture_$name", mode) + } + manager = context.getSystemService(NotificationManager::class.java) + firebaseApp = FirebaseApp.getApps(context).firstOrNull { it.name == "fcm-notification-fixture" } + ?: FirebaseApp.initializeApp( + context, + FirebaseOptions + .Builder() + .setProjectId("demo-slowclock-fcm") + .setApplicationId("1:123456789:android:abcdef") + .setApiKey("fake-api-key-for-local-emulator-only") + .build(), + "fcm-notification-fixture", + ) + val firebaseAuth = FirebaseAuth.getInstance(firebaseApp) + firebaseAuth.useEmulator("10.0.2.2", port!!) + Tasks.await(firebaseAuth.signInAnonymously(), 20, TimeUnit.SECONDS) + auth = AuthRepository(firebaseAuth) + settings = SettingsRepository(context) + settings.setShareCode("CODE01") + notifier = SharedScheduleNotifier(context, auth, settings) + message = SharedScheduleMessage(auth.currentUid!!, "CODE01", "id", "Fixture change", "Fixture schedule") + notifier.changeSession {} + } + + @After + fun tearDown() { + if (::notifier.isInitialized) { + notifier.changeSession { + settings.clearShareCode() + auth.signOut() + } + } + if (::manager.isInitialized) manager.cancel("fcm-fixture-alarm", 704) + } + + @Test + fun currentSessionOnlyAndDistinctScheduleIds() { + assertTrue(manager.areNotificationsEnabled()) + assertTrue(notifier.show(message)) + assertFalse(notifier.show(message.copy(recipientUid = "other"))) + assertFalse(notifier.show(message.copy(shareCode = "OTHER1"))) + awaitSharedCount(1) + assertTrue(notifier.show(message.copy(title = "Updated"))) + awaitSharedCount(1) + assertTrue(notifier.show(message.copy(scheduleId = "Aa"))) + assertTrue(notifier.show(message.copy(scheduleId = "BB"))) + awaitSharedCount(3) + } + + @Test + fun logoutClearsNewAndLegacySharedNotificationsButKeepsAlarm() { + assertTrue(notifier.show(message)) + manager.createNotificationChannel(NotificationChannel("fcm-fixture-alarm", "Fixture alarm", NotificationManager.IMPORTANCE_DEFAULT)) + manager.createNotificationChannel( + NotificationChannel("fcm_fallback_notification_channel", "Legacy fixture", NotificationManager.IMPORTANCE_DEFAULT), + ) + + fun notification(channel: String) = + Notification + .Builder(context, channel) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle("Fixture") + .build() + manager.notify("fcm-fixture-alarm", 704, notification("fcm-fixture-alarm")) + manager.notify(0, notification("schedule_channel")) + manager.notify("FCM-Notification:fixture", 0, notification("fcm_fallback_notification_channel")) + awaitCondition { manager.activeNotifications.size == 4 } + notifier.changeSession { + settings.clearShareCode() + auth.signOut() + } + awaitCondition { manager.activeNotifications.size == 1 } + assertEquals("fcm-fixture-alarm", manager.activeNotifications.single().tag) + assertFalse(notifier.show(message)) + awaitSharedCount(0) + } + + @Test + fun codeReplacementAndUnregisterRemovePreviousNotifications() { + assertTrue(notifier.show(message)) + awaitSharedCount(1) + assertTrue(notifier.replaceShareCode(notifier.snapshot(), "NEW002")) + awaitSharedCount(0) + assertFalse(notifier.show(message)) + assertTrue(notifier.show(message.copy(shareCode = "NEW002"))) + awaitSharedCount(1) + assertTrue(notifier.replaceShareCode(notifier.snapshot(), null)) + awaitSharedCount(0) + assertFalse(notifier.show(message.copy(shareCode = "NEW002"))) + } + + @Test + fun permissionDeniedDoesNotPost() { + assumeTrue( + "Run separately with POST_NOTIFICATIONS revoked", + InstrumentationRegistry.getArguments().getString("sharedPermissionDenied") == "true", + ) + assertFalse(manager.areNotificationsEnabled()) + assertFalse(notifier.show(message)) + awaitSharedCount(0) + } + + private fun awaitSharedCount(count: Int) = + awaitCondition { + manager.activeNotifications.count { it.tag?.startsWith(SharedScheduleNotifier.TAG_PREFIX) == true } == count + } + + private fun awaitCondition(condition: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!condition() && System.nanoTime() < deadline) Thread.sleep(50) + assertTrue(condition()) + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..0167c3b3 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/debug/res/xml/debug_network_security_config.xml b/app/src/debug/res/xml/debug_network_security_config.xml new file mode 100644 index 00000000..0b48850c --- /dev/null +++ b/app/src/debug/res/xml/debug_network_security_config.xml @@ -0,0 +1,8 @@ + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/core/alarm/build.gradle.kts b/core/alarm/build.gradle.kts index 25835fbe..accb43da 100644 --- a/core/alarm/build.gradle.kts +++ b/core/alarm/build.gradle.kts @@ -15,6 +15,9 @@ dependencies { testImplementation(libs.mockk) implementation(project(":core:data")) // Notifier 인터페이스 구현 (역결합 제거) implementation(libs.androidx.core.ktx) - implementation(libs.firebase.messaging) + // FCMService의 공개 superclass/메시지 인자와 Hilt service 계층을 앱 lint에서도 해석한다. + // 전이 SDK의 R 클래스는 Preview compile classpath에 노출하지 않고 runtime에는 모두 유지한다. + api(libs.firebase.messaging) { isTransitive = false } + runtimeOnly(libs.firebase.messaging) implementation(libs.firebase.auth) } diff --git a/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt b/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt index 89ef9e33..628dd640 100644 --- a/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt +++ b/core/alarm/src/main/java/com/example/slowclock/notification/FCMService.kt @@ -1,80 +1,30 @@ package com.example.slowclock.notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import androidx.core.app.NotificationCompat -import com.example.slowclock.core.alarm.R +import com.example.slowclock.data.remote.repository.UserRepository import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class FCMService : FirebaseMessagingService() { + @Inject lateinit var sharedScheduleNotifier: SharedScheduleNotifier + + @Inject lateinit var userRepository: UserRepository + override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) - val title = remoteMessage.notification?.title ?: "일정 알림" - val body = remoteMessage.notification?.body ?: "새로운 알림이 있습니다." - android.util.Log.d("FCMService", "onMessageReceived: title=$title, body=$body, data=${remoteMessage.data}") - sendNotification(title, body) + // notification payload는 background에서 SDK가 직접 표시하므로 서버도 data-only로 바꾼다. + // https://firebase.google.com/docs/cloud-messaging/android/receive-messages + if (remoteMessage.notification != null) return + val message = SharedScheduleMessage.fromData(remoteMessage.data) ?: return + sharedScheduleNotifier.show(message) } override fun onNewToken(token: String) { super.onNewToken(token) - // Firestore에 FCM 토큰 저장 - val user = - com.google.firebase.auth.FirebaseAuth - .getInstance() - .currentUser - user?.let { - val db = - com.google.firebase.firestore.FirebaseFirestore - .getInstance() - db - .collection("users") - .document(it.uid) - .update("fcmToken", token) - .addOnSuccessListener { android.util.Log.d("FCM", "토큰 Firestore 저장 성공") } - .addOnFailureListener { e -> android.util.Log.e("FCM", "토큰 Firestore 저장 실패", e) } + sharedScheduleNotifier.withCurrentSession { session -> + userRepository.updateFcmRegistration(token, session.userId.orEmpty(), session.shareCode) } } - - private fun sendNotification( - title: String, - messageBody: String, - ) { - val intent = - Intent().setClassName(this, "com.example.slowclock.MainActivity").apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - val pendingIntent = - PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE, - ) - - val channelId = "schedule_channel" - val notificationBuilder = - NotificationCompat - .Builder(this, channelId) - .setSmallIcon(R.drawable.baseline_access_alarm_24) - .setContentTitle(title) - .setContentText(messageBody) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - - // 여기서 Context.NOTIFICATION_SERVICE 대신 NOTIFICATION_SERVICE만 써보기 - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - - val channel = - NotificationChannel( - channelId, - "일정 알림", - NotificationManager.IMPORTANCE_HIGH, - ) - notificationManager.createNotificationChannel(channel) - notificationManager.notify(0, notificationBuilder.build()) - } } diff --git a/core/alarm/src/main/java/com/example/slowclock/notification/SharedNotificationDelivery.kt b/core/alarm/src/main/java/com/example/slowclock/notification/SharedNotificationDelivery.kt new file mode 100644 index 00000000..25d19fa5 --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/notification/SharedNotificationDelivery.kt @@ -0,0 +1,81 @@ +package com.example.slowclock.notification + +/** 네트워크 대기 전후를 같은 로그인·공유 설정 작업으로 묶는 스냅샷. */ +data class SharedNotificationSession( + val userId: String?, + val shareCode: String?, + val revision: Long, +) + +/** 검사와 표시 사이에 로그아웃/코드 변경이 끼어들지 않게 직렬화한다. */ +internal class SharedNotificationDelivery( + private val readSession: () -> Pair, + private val cancelNotifications: () -> Unit, +) { + private val lock = Any() + private var revision = 0L + + fun snapshot(): SharedNotificationSession = synchronized(lock) { currentSession() } + + fun showIfCurrent( + message: SharedScheduleMessage, + show: () -> Boolean, + ): Boolean = + synchronized(lock) { + val session = currentSession() + if (session.userId != message.recipientUid || session.shareCode != message.shareCode) return@synchronized false + show() + } + + fun changeSession(change: () -> Unit) = + synchronized(lock) { + revision++ + try { + change() + } finally { + cancelNotifications() + } + } + + fun changeIfCurrent( + expected: SharedNotificationSession, + change: () -> Unit, + ): Boolean = + synchronized(lock) { + if (expected.userId.isNullOrBlank() || currentSession() != expected) return@synchronized false + changeSession(change) + true + } + + fun runIfCurrent( + expected: SharedNotificationSession, + action: () -> Unit, + ): Boolean = + synchronized(lock) { + if (currentSession() != expected) return@synchronized false + action() + true + } + + fun changeAfterAccountDeletion( + expected: SharedNotificationSession, + change: () -> Unit, + ): Boolean = + synchronized(lock) { + val current = currentSession() + if (current != expected && current != expected.copy(userId = null)) return@synchronized false + changeSession(change) + true + } + + fun withCurrentSession(action: (SharedNotificationSession) -> Unit) = + synchronized(lock) { + val session = currentSession() + if (!session.userId.isNullOrBlank()) action(session) + } + + private fun currentSession(): SharedNotificationSession { + val (uid, code) = readSession() + return SharedNotificationSession(uid, code, revision) + } +} diff --git a/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleMessage.kt b/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleMessage.kt new file mode 100644 index 00000000..be46b6c3 --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleMessage.kt @@ -0,0 +1,22 @@ +package com.example.slowclock.notification + +/** 서버 data-only 공유 알림 계약. 필수 대상이 빠진 메시지를 일반 알림으로 바꾸지 않는다. */ +data class SharedScheduleMessage( + val recipientUid: String, + val shareCode: String, + val scheduleId: String, + val title: String, + val body: String, +) { + companion object { + fun fromData(data: Map): SharedScheduleMessage? { + if (data["type"] != "shared_schedule" || data["schemaVersion"] != "1") return null + val uid = data["recipientUid"]?.takeIf { it.isNotBlank() } ?: return null + val code = data["shareCode"]?.takeIf { it.isNotBlank() } ?: return null + val id = data["scheduleId"]?.takeIf { it.isNotBlank() } ?: return null + val title = data["title"]?.takeIf { it.isNotBlank() } ?: return null + val body = data["body"] ?: return null + return SharedScheduleMessage(uid, code, id, title, body) + } + } +} diff --git a/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleNotifier.kt b/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleNotifier.kt new file mode 100644 index 00000000..42873fcf --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/notification/SharedScheduleNotifier.kt @@ -0,0 +1,115 @@ +package com.example.slowclock.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.example.slowclock.core.alarm.R +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** 공유 푸시의 표시와 세션 변경을 한 인스턴스에서 관리한다. 정시 알람에는 관여하지 않는다. */ +@Singleton +class SharedScheduleNotifier + @Inject + constructor( + @ApplicationContext private val context: Context, + private val authRepository: AuthRepository, + private val settingsRepository: SettingsRepository, + ) { + private val manager: NotificationManager + get() = context.getSystemService(NotificationManager::class.java) + private val delivery = + SharedNotificationDelivery( + readSession = { authRepository.currentUid to settingsRepository.getShareCode() }, + cancelNotifications = ::cancelSharedNotifications, + ) + + fun snapshot(): SharedNotificationSession = delivery.snapshot() + + /** 호출자는 서버 응답 대기나 suspend 작업을 이 동기 구간 안에 넣지 않는다. */ + fun changeSession(change: () -> Unit) = delivery.changeSession(change) + + fun replaceShareCode( + expected: SharedNotificationSession, + code: String?, + ): Boolean = + delivery.changeIfCurrent(expected) { + if (code.isNullOrBlank()) settingsRepository.clearShareCode() else settingsRepository.setShareCode(code) + } + + fun runIfCurrent( + expected: SharedNotificationSession, + action: () -> Unit, + ): Boolean = delivery.runIfCurrent(expected, action) + + fun clearDeletedAccount(expected: SharedNotificationSession): Boolean = + delivery.changeAfterAccountDeletion(expected) { + settingsRepository.clearShareCode() + } + + fun withCurrentSession(action: (SharedNotificationSession) -> Unit) = delivery.withCurrentSession(action) + + fun show(message: SharedScheduleMessage): Boolean = + delivery.showIfCurrent(message) { + if (!manager.areNotificationsEnabled()) return@showIfCurrent false + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.shared_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ), + ) + if (manager.getNotificationChannel(CHANNEL_ID)?.importance == + NotificationManager.IMPORTANCE_NONE + ) { + return@showIfCurrent false + } + val intent = + Intent().setClassName(context, "com.example.slowclock.MainActivity").apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.baseline_access_alarm_24) + .setContentTitle(message.title) + .setContentText(message.body) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + try { + manager.notify(notificationTag(message), 0, notification) + true + } catch (_: SecurityException) { + false + } + } + + private fun cancelSharedNotifications() { + manager.activeNotifications + .filter { + it.tag?.startsWith(TAG_PREFIX) == true || + (it.tag == null && it.id == 0 && it.notification.channelId == CHANNEL_ID) || + // 구 sender의 notification payload는 Firebase SDK가 별도 tag/기본 채널로 표시했다. + ( + it.id == 0 && it.tag?.startsWith("FCM-Notification:") == true && + it.notification.channelId == "fcm_fallback_notification_channel" + ) + }.forEach { manager.cancel(it.tag, it.id) } + } + + companion object { + const val CHANNEL_ID = "schedule_channel" + const val TAG_PREFIX = "shared_schedule:" + + internal fun notificationTag(message: SharedScheduleMessage): String = + "$TAG_PREFIX${message.recipientUid.length}:${message.recipientUid}${message.shareCode.length}:${message.shareCode}${message.scheduleId}" + } + } diff --git a/core/alarm/src/main/res/values/strings.xml b/core/alarm/src/main/res/values/strings.xml index 6c88a463..b2e466a2 100644 --- a/core/alarm/src/main/res/values/strings.xml +++ b/core/alarm/src/main/res/values/strings.xml @@ -7,5 +7,6 @@ 다시 알림 (%1$d분) 알람 끄기 알람 + 공유 일정 알림 다시 알림을 예약하지 못했습니다. 알람은 계속 울립니다. 다시 시도하거나 알람을 꺼 주세요. diff --git a/core/alarm/src/test/java/com/example/slowclock/notification/FCMServiceTest.kt b/core/alarm/src/test/java/com/example/slowclock/notification/FCMServiceTest.kt new file mode 100644 index 00000000..e3d42511 --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/notification/FCMServiceTest.kt @@ -0,0 +1,58 @@ +package com.example.slowclock.notification + +import com.example.slowclock.data.remote.repository.UserRepository +import com.google.firebase.messaging.RemoteMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class FCMServiceTest { + private val notifier = mockk(relaxed = true) + private val users = mockk(relaxed = true) + private val service = + FCMService().apply { + sharedScheduleNotifier = notifier + userRepository = users + } + private val payload = + mapOf( + "type" to "shared_schedule", + "schemaVersion" to "1", + "recipientUid" to "uid", + "shareCode" to "CODE01", + "scheduleId" to "id", + "title" to "일정", + "body" to "본문", + ) + + @Test + fun `data-only 계약만 현재 세션 확인 경로로 보낸다`() { + val remote = mockk() + every { remote.notification } returns null + every { remote.data } returns payload + service.onMessageReceived(remote) + verify(exactly = 1) { notifier.show(SharedScheduleMessage.fromData(payload)!!) } + } + + @Test + fun `legacy notification과 불완전한 data는 기본 문구로 표시하지 않는다`() { + val legacy = mockk() + every { legacy.notification } returns mockk() + service.onMessageReceived(legacy) + val invalid = mockk() + every { invalid.notification } returns null + every { invalid.data } returns payload - "recipientUid" + service.onMessageReceived(invalid) + verify(exactly = 0) { notifier.show(any()) } + } + + @Test + fun `토큰 갱신은 잠금 안의 현재 세션으로 저장소에 전달한다`() { + every { notifier.withCurrentSession(any()) } answers { + firstArg<(SharedNotificationSession) -> Unit>().invoke(SharedNotificationSession("uid", "CODE01", 1)) + } + service.onNewToken("refreshed") + verify(exactly = 1) { users.updateFcmRegistration("refreshed", "uid", "CODE01") } + } +} diff --git a/core/alarm/src/test/java/com/example/slowclock/notification/SharedNotificationDeliveryTest.kt b/core/alarm/src/test/java/com/example/slowclock/notification/SharedNotificationDeliveryTest.kt new file mode 100644 index 00000000..9a1b10f3 --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/notification/SharedNotificationDeliveryTest.kt @@ -0,0 +1,148 @@ +package com.example.slowclock.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class SharedNotificationDeliveryTest { + private var uid: String? = "uid" + private var code: String? = "CODE01" + private val visible = mutableSetOf() + private val delivery = SharedNotificationDelivery({ uid to code }, { visible.clear() }) + private val message = SharedScheduleMessage("uid", "CODE01", "schedule", "일정", "본문") + + @Test + fun `현재 계정과 공유 코드가 모두 일치해야 표시한다`() { + assertTrue(delivery.showIfCurrent(message) { visible.add("schedule") }) + for ((nextUid, nextCode) in listOf(null to "CODE01", "other" to "CODE01", "uid" to null, "uid" to "CODE02")) { + delivery.changeSession { + uid = nextUid + code = nextCode + } + assertFalse(delivery.showIfCurrent(message) { error("잘못된 세션에 표시") }) + } + assertTrue(visible.isEmpty()) + } + + @Test + fun `표시가 먼저 시작되어도 로그아웃 반환 뒤 알림이 남지 않는다`() { + assertNotifyThenChange { + uid = null + code = null + } + } + + @Test + fun `공유 코드 교체도 검사와 표시 사이에 끼어들지 않는다`() { + assertNotifyThenChange { code = "CODE02" } + assertFalse(delivery.showIfCurrent(message) { error("이전 코드 표시") }) + } + + private fun assertNotifyThenChange(change: () -> Unit) { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val changeStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + try { + val receive = + executor.submit { + delivery.showIfCurrent(message) { + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + visible.add("schedule") + } + } + assertTrue(entered.await(5, TimeUnit.SECONDS)) + val mutate = + executor.submit { + changeStarted.countDown() + delivery.changeSession(change) + } + assertTrue(changeStarted.await(5, TimeUnit.SECONDS)) + assertFalse(mutate.isDone) + release.countDown() + assertTrue(receive.get(5, TimeUnit.SECONDS)) + mutate.get(5, TimeUnit.SECONDS) + assertTrue(visible.isEmpty()) + } finally { + release.countDown() + executor.shutdownNow() + } + } + + @Test + fun `로그아웃이 먼저 완료되면 뒤 수신은 표시하지 않는다`() { + delivery.changeSession { + uid = null + code = null + } + assertFalse(delivery.showIfCurrent(message) { error("로그아웃 뒤 표시") }) + } + + @Test + fun `같은 계정으로 다시 로그인해도 앞 세션의 저장 완료를 반영하지 않는다`() { + val oldSession = delivery.snapshot() + delivery.changeSession { + uid = null + code = null + } + delivery.changeSession { + uid = "uid" + code = "CODE01" + } + assertFalse(delivery.changeIfCurrent(oldSession) { code = "OLD_SAVE" }) + assertEquals("CODE01", code) + } + + @Test + fun `세션 변경 중 예외가 나도 기존 공유 알림은 지운다`() { + visible.add("schedule") + try { + delivery.changeSession { + code = null + error("auth failure") + } + } catch (_: IllegalStateException) { + // Auth 실패를 호출자에 전달하더라도 이미 뜬 알림은 남기지 않는다. + } + assertTrue(visible.isEmpty()) + } + + @Test + fun `토큰 갱신은 현재 로그인한 세션만 넘긴다`() { + val submitted = mutableListOf() + delivery.withCurrentSession { submitted.add(it) } + delivery.changeSession { + uid = null + code = null + } + delivery.withCurrentSession { error("로그아웃 뒤 등록") } + assertEquals(listOf(SharedNotificationSession("uid", "CODE01", 0)), submitted) + } + + @Test + fun `Auth 삭제 완료 뒤에도 같은 세션의 공유 알림을 지운다`() { + val session = delivery.snapshot() + visible.add("schedule") + uid = null + assertTrue(delivery.changeAfterAccountDeletion(session) { code = null }) + assertTrue(visible.isEmpty()) + } + + @Test + fun `늦은 계정 삭제 응답이 새 로그인 알림과 코드를 지우지 않는다`() { + val session = delivery.snapshot() + delivery.changeSession { + uid = "new-uid" + code = "NEW002" + } + visible.add("new schedule") + assertFalse(delivery.changeAfterAccountDeletion(session) { error("새 설정 삭제") }) + assertEquals(setOf("new schedule"), visible) + assertEquals("NEW002", code) + } +} diff --git a/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleMessageTest.kt b/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleMessageTest.kt new file mode 100644 index 00000000..e34ee74c --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleMessageTest.kt @@ -0,0 +1,52 @@ +package com.example.slowclock.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SharedScheduleMessageTest { + private val data = + mapOf( + "type" to "shared_schedule", + "schemaVersion" to "1", + "recipientUid" to "uid", + "shareCode" to "CODE01", + "scheduleId" to "id", + "title" to "추가됨", + "body" to "약 먹기", + ) + + @Test + fun `필수 필드가 빠지거나 계약이 다르면 기본 알림을 만들지 않는다`() { + for (key in data.keys) assertNull("누락된 필드: $key", SharedScheduleMessage.fromData(data - key)) + for (key in data.keys - "body") assertNull("빈 필드: $key", SharedScheduleMessage.fromData(data + (key to " "))) + assertNull(SharedScheduleMessage.fromData(data + ("schemaVersion" to "2"))) + assertNull(SharedScheduleMessage.fromData(data + ("type" to "other"))) + assertNull(SharedScheduleMessage.fromData(emptyMap())) + } + + @Test + fun `일정 제목이 없는 삭제라도 UID 코드 일정ID가 있으면 처리한다`() { + assertNotNull(SharedScheduleMessage.fromData(data + ("body" to ""))) + } + + @Test + fun `같은 일정은 갱신하고 다른 일정이나 계정의 tag는 겹치지 않는다`() { + val message = SharedScheduleMessage.fromData(data)!! + val tag = SharedScheduleNotifier.notificationTag(message) + assertEquals(tag, SharedScheduleNotifier.notificationTag(message.copy(title = "삭제됨"))) + assertNotEquals(tag, SharedScheduleNotifier.notificationTag(message.copy(scheduleId = "other"))) + assertNotEquals(tag, SharedScheduleNotifier.notificationTag(message.copy(recipientUid = "other"))) + val left = message.copy(recipientUid = "a", shareCode = "bc", scheduleId = "d") + val right = message.copy(recipientUid = "ab", shareCode = "c", scheduleId = "d") + assertNotEquals(SharedScheduleNotifier.notificationTag(left), SharedScheduleNotifier.notificationTag(right)) + // Java hash collision처럼 일정 ID가 같은 정수로 줄어들어도 tag 문자열은 다르다. + assertEquals("Aa".hashCode(), "BB".hashCode()) + assertNotEquals( + SharedScheduleNotifier.notificationTag(message.copy(scheduleId = "Aa")), + SharedScheduleNotifier.notificationTag(message.copy(scheduleId = "BB")), + ) + } +} diff --git a/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleNotifierTest.kt b/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleNotifierTest.kt new file mode 100644 index 00000000..c2567b32 --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/notification/SharedScheduleNotifierTest.kt @@ -0,0 +1,82 @@ +package com.example.slowclock.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.service.notification.StatusBarNotification +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Test + +class SharedScheduleNotifierTest { + private val context = mockk(relaxed = true) + private val manager = mockk(relaxed = true) + private val auth = mockk() + private val settings = mockk() + private val notifier: SharedScheduleNotifier + private val message = SharedScheduleMessage("uid", "CODE01", "id", "일정", "본문") + + init { + every { context.getSystemService(NotificationManager::class.java) } returns manager + every { auth.currentUid } returns "uid" + every { settings.getShareCode() } returns "CODE01" + every { manager.activeNotifications } returns emptyArray() + notifier = SharedScheduleNotifier(context, auth, settings) + } + + @Test + fun `알림 권한이 꺼지면 알림을 만들거나 게시하지 않는다`() { + every { manager.areNotificationsEnabled() } returns false + assertFalse(notifier.show(message)) + verify(exactly = 0) { manager.createNotificationChannel(any()) } + verify(exactly = 0) { manager.notify(any(), any(), any()) } + } + + @Test + fun `공유 채널이 꺼져 있으면 게시하지 않는다`() { + val channel = mockk() + every { manager.areNotificationsEnabled() } returns true + every { manager.getNotificationChannel(SharedScheduleNotifier.CHANNEL_ID) } returns channel + every { channel.importance } returns NotificationManager.IMPORTANCE_NONE + assertFalse(notifier.show(message)) + verify(exactly = 0) { manager.notify(any(), any(), any()) } + } + + @Test + fun `공유 알림만 지우고 정시 알람과 놓친 알림은 남긴다`() { + every { manager.activeNotifications } returns + arrayOf( + active("shared_schedule:some-id", 0, "schedule_channel"), + active(null, 0, "schedule_channel"), + active("FCM-Notification:123", 0, "fcm_fallback_notification_channel"), + active(null, 77, "alarm_channel"), + active(null, 0, "missed_alarm_channel"), + active("other-tag", 0, "schedule_channel"), + active("FCM-Notification:456", 8, "alarm_channel"), + ) + notifier.changeSession {} + verify(exactly = 1) { manager.cancel("shared_schedule:some-id", 0) } + verify(exactly = 1) { manager.cancel(null, 0) } + verify(exactly = 1) { manager.cancel("FCM-Notification:123", 0) } + verify(exactly = 1) { manager.activeNotifications } + confirmVerified(manager) + verify(exactly = 0) { manager.cancelAll() } + } + + private fun active( + tag: String?, + id: Int, + channel: String, + ): StatusBarNotification = + mockk { + every { this@mockk.tag } returns tag + every { this@mockk.id } returns id + every { notification } returns mockk { every { channelId } returns channel } + } +} diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/AuthRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/AuthRepository.kt index f7d04a0f..8e5c8c31 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/AuthRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/AuthRepository.kt @@ -2,7 +2,10 @@ package com.example.slowclock.data.remote.repository import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.tasks.await @@ -65,11 +68,15 @@ class AuthRepository } /** Auth 사용자를 지운다. 성공하면 Firebase 가 세션도 끝낸다. */ - suspend fun deleteCurrentUser(): DeleteResult { + suspend fun deleteCurrentUser(expectedUid: String): DeleteResult { + currentCoroutineContext().ensureActive() val user = auth.currentUser ?: return DeleteResult.NotSignedIn + if (user.uid != expectedUid) return DeleteResult.NotSignedIn return try { user.delete().await() DeleteResult.Success + } catch (e: CancellationException) { + throw e } catch (e: FirebaseAuthRecentLoginRequiredException) { DeleteResult.RecentLoginRequired } catch (e: Exception) { diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt index bd0b201c..8a774849 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -11,6 +11,7 @@ import com.google.firebase.firestore.FirebaseFirestoreException import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.toObject import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.awaitClose @@ -230,14 +231,36 @@ class UserRepository val uid = auth.currentUser?.uid ?: return false return try { val token = messaging.token.await() + if (auth.currentUser?.uid != uid) return false usersCollection.document(uid).update("fcmToken", token).await() true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Log.e(TAG, "FCM 토큰 저장 실패", e) + Log.e(TAG, "FCM 토큰 저장 실패") false } } + /** FCM 콜백은 서버 응답을 기다리지 않고 현재 세션의 갱신 쓰기만 제출한다. */ + fun updateFcmRegistration( + token: String, + expectedUid: String, + shareCode: String?, + ) { + if (token.isBlank() || expectedUid.isBlank() || auth.currentUser?.uid != expectedUid) return + usersCollection + .document(expectedUid) + .update("fcmToken", token) + .addOnFailureListener { Log.w(TAG, "사용자 FCM 등록 갱신 실패") } + if (!shareCode.isNullOrBlank() && auth.currentUser?.uid == expectedUid) { + shareCodeWatcherTokens(shareCode) + .document(expectedUid) + .set(mapOf("userId" to expectedUid, "fcmToken" to token), SetOptions.merge()) + .addOnFailureListener { Log.w(TAG, "공유 FCM 등록 갱신 실패") } + } + } + /** * 공유 코드 감시자로 이 기기를 등록한다. * @@ -248,26 +271,42 @@ class UserRepository suspend fun registerShareCodeWatcher(shareCode: String): Boolean { val uid = auth.currentUser?.uid ?: return false return try { - val token = runCatching { messaging.token.await() }.getOrNull() + val token = + try { + messaging.token.await() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if (auth.currentUser?.uid != uid) return false if (token == null) Log.w(TAG, "FCM 토큰 없이 감시자를 등록한다. 알림은 다음 등록에서 붙는다") val fields = mutableMapOf("userId" to uid) token?.let { fields["fcmToken"] = it } // merge 로 쓴다. 토큰을 못 받은 등록이 앞서 저장해 둔 토큰을 지우면 안 된다. shareCodeWatcherTokens(shareCode).document(uid).set(fields, SetOptions.merge()).await() true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Log.e(TAG, "공유 코드 감시자 등록 실패", e) + Log.e(TAG, "공유 코드 감시자 등록 실패") false } } - suspend fun unregisterShareCodeWatcher(shareCode: String): Boolean { + suspend fun unregisterShareCodeWatcher( + shareCode: String, + expectedUid: String, + ): Boolean { val uid = auth.currentUser?.uid ?: return false + if (uid != expectedUid) return false return try { shareCodeWatcherTokens(shareCode).document(uid).delete().await() true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Log.e(TAG, "공유 코드 감시자 해제 실패", e) + Log.e(TAG, "공유 코드 감시자 해제 실패") false } } diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/AuthRepositoryDeleteTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/AuthRepositoryDeleteTest.kt new file mode 100644 index 00000000..e83f9542 --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/AuthRepositoryDeleteTest.kt @@ -0,0 +1,62 @@ +package com.example.slowclock.data.remote.repository + +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthRepositoryDeleteTest { + private val firebaseAuth = mockk() + private val oldUser = mockk() + private val newUser = mockk() + private val repository = AuthRepository(firebaseAuth) + private val response = TaskCompletionSource() + + init { + every { oldUser.uid } returns "old" + every { newUser.uid } returns "new" + every { firebaseAuth.currentUser } returns oldUser + every { oldUser.delete() } returns response.task + } + + @Test + fun `현재 사용자가 기대한 UID와 다르면 Auth 삭제를 시작하지 않는다`() = + runTest { + every { firebaseAuth.currentUser } returns newUser + assertEquals(AuthRepository.DeleteResult.NotSignedIn, repository.deleteCurrentUser("old")) + verify(exactly = 0) { newUser.delete() } + verify(exactly = 0) { oldUser.delete() } + } + + @Test + fun `삭제 응답을 기다리는 동안 계정이 바뀌어도 새 사용자를 다시 고르지 않는다`() = + runTest { + val result = async { repository.deleteCurrentUser("old") } + runCurrent() + every { firebaseAuth.currentUser } returns newUser + response.setResult(null) + assertEquals(AuthRepository.DeleteResult.Success, result.await()) + verify(exactly = 1) { oldUser.delete() } + verify(exactly = 0) { newUser.delete() } + } + + @Test + fun `Auth 삭제 대기 취소는 일반 실패로 바꾸지 않는다`() = + runTest { + val result = async { repository.deleteCurrentUser("old") } + runCurrent() + result.cancel() + runCurrent() + assertTrue(result.isCancelled) + } +} diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryFcmRegistrationTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryFcmRegistrationTest.kt new file mode 100644 index 00000000..e5c7543d --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/UserRepositoryFcmRegistrationTest.kt @@ -0,0 +1,129 @@ +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.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import com.google.firebase.messaging.FirebaseMessaging +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UserRepositoryFcmRegistrationTest { + private val auth = mockk() + private val firestore = mockk() + private val messaging = mockk() + private val user = mockk() + private val users = mockk() + private val userDocument = mockk() + private val watchers = mockk() + private val watcherDocument = mockk() + private val repository: UserRepository + + init { + every { user.uid } returns "uid" + every { auth.currentUser } returns user + every { firestore.collection(any()) } returns mockk() + every { firestore.collection("users") } returns users + every { users.document("uid") } returns userDocument + every { userDocument.update("fcmToken", any()) } returns Tasks.forResult(null) + val codes = mockk() + val code = mockk() + every { firestore.collection("shareCodeWatchers") } returns codes + every { codes.document("CODE01") } returns code + every { code.collection("tokens") } returns watchers + every { watchers.document("uid") } returns watcherDocument + every { watcherDocument.set(any(), any()) } returns Tasks.forResult(null) + repository = UserRepository(auth, firestore, messaging) + } + + @Test + fun `갱신된 토큰은 사용자와 현재 공유 감시자에 같이 쓴다`() { + repository.updateFcmRegistration("new-token", "uid", "CODE01") + verify(exactly = 1) { userDocument.update("fcmToken", "new-token") } + verify(exactly = 1) { watcherDocument.set(mapOf("userId" to "uid", "fcmToken" to "new-token"), any()) } + verify(exactly = 0) { watcherDocument.delete() } + } + + @Test + fun `코드를 해제한 세션은 감시자를 재생성하지 않는다`() { + repository.updateFcmRegistration("new-token", "uid", null) + verify(exactly = 1) { userDocument.update("fcmToken", "new-token") } + verify(exactly = 0) { watchers.document(any()) } + } + + @Test + fun `다른 계정이나 로그아웃 뒤 도착한 갱신은 이전 경로를 쓰지 않는다`() { + repository.updateFcmRegistration("new-token", "other-uid", "CODE01") + every { auth.currentUser } returns null + repository.updateFcmRegistration("new-token", "uid", "CODE01") + verify(exactly = 0) { userDocument.update("fcmToken", any()) } + verify(exactly = 0) { watcherDocument.set(any(), any()) } + } + + @Test + fun `빈 토큰으로 앞 등록을 덮지 않는다`() { + repository.updateFcmRegistration(" ", "uid", "CODE01") + verify(exactly = 0) { userDocument.update("fcmToken", any()) } + verify(exactly = 0) { watcherDocument.set(any(), any()) } + } + + @Test + fun `토큰 조회 중 로그아웃하면 감시자를 뒤늦게 등록하지 않는다`() = + runTest { + val token = TaskCompletionSource() + every { messaging.token } returns token.task + val result = async { repository.registerShareCodeWatcher("CODE01") } + runCurrent() + every { auth.currentUser } returns null + token.setResult("late-token") + assertFalse(result.await()) + verify(exactly = 0) { watcherDocument.set(any(), any()) } + } + + @Test + fun `사용자 토큰 조회 중 계정이 바뀌면 앞 사용자에 쓰지 않는다`() = + runTest { + val token = TaskCompletionSource() + every { messaging.token } returns token.task + val result = async { repository.saveCurrentUserFcmToken() } + runCurrent() + val nextUser = mockk() + every { nextUser.uid } returns "next" + every { auth.currentUser } returns nextUser + token.setResult("late-token") + assertFalse(result.await()) + verify(exactly = 0) { userDocument.update("fcmToken", any()) } + } + + @Test + fun `토큰 조회 취소는 토큰 없는 새 등록으로 바꾸지 않는다`() = + runTest { + val token = TaskCompletionSource() + every { messaging.token } returns token.task + val result = async { repository.registerShareCodeWatcher("CODE01") } + runCurrent() + result.cancel() + runCurrent() + token.setResult("late-token") + verify(exactly = 0) { watcherDocument.set(any(), any()) } + } + + @Test + fun `기대 UID와 다르면 현재 기기의 다른 사용자 감시자를 지우지 않는다`() = + runTest { + assertFalse(repository.unregisterShareCodeWatcher("CODE01", "other")) + verify(exactly = 0) { watcherDocument.delete() } + } +} diff --git a/docs/testing/shared-notifications.md b/docs/testing/shared-notifications.md new file mode 100644 index 00000000..64ee692e --- /dev/null +++ b/docs/testing/shared-notifications.md @@ -0,0 +1,79 @@ +# 공유 일정 푸시의 세션 확인 + +#206부터 sender와 Android 앱이 함께 `shared_schedule` v1 계약을 사용한다. 로그아웃하거나 다른 공유 코드를 저장한 기기는 이전 계정·코드의 새 푸시를 표시하지 않는다. FCM 전송/수신은 정시 알람과 별개다. + +## 데이터 계약 + +Cloud Function `sendFcmToShareCodeWatchers`는 `notification` 없이 다음 문자열 data 필드를 보낸다. + +| 필드 | 값/근거 | +|---|---| +| type / schemaVersion | `shared_schedule` / `1` | +| recipientUid | `shareCodeWatchers/{code}/tokens/{uid}`의 문서 ID. 문서 데이터의 `userId`를 신뢰하지 않는다. | +| shareCode | 변경된 일정의 공유 코드. 삭제에는 before 값을 사용한다. | +| scheduleId | Firestore trigger 경로의 일정 ID | +| title / body | 변경 종류 안내 / 일정 제목. body는 빈 문자열일 수 있다. | + +수신자는 모든 필수 필드와 현재 Auth UID·로컬 공유 코드를 확인한다. 필드 누락, 알 수 없는 버전, legacy notification payload는 기본 알림으로 대체하지 않는다. 수신 hot path에는 원격 조회나 suspend 작업이 없다. 앱 알림 권한 또는 공유 알림 채널이 차단된 경우 표시하지 않는다. + +서버는 `(recipientUid, token)`을 중복 제거하고 `sendEach`에 최대 500개씩 넘긴다. 한 토큰이 이전 UID와 현재 UID에 모두 남아 있어도 현재 UID 메시지를 보존한다. 로그에는 전송 성공/실패 수만 남긴다. Android FCM 수신 데이터·토큰은 로그에 쓰지 않는다. + +## 세션과 이미 표시된 알림 + +`SharedScheduleNotifier`의 단일 잠금에서 현재 세션 확인부터 `notify`까지 실행한다. 로그아웃의 로컬 코드 삭제·Auth signOut·공유 알림 정리, 코드 변경·해제도 같은 잠금을 사용한다. 알림 표시가 먼저 시작되면 세션 변경이 그 알림을 지운 뒤 반환한다. 변경이 먼저 완료되면 이전 메시지는 표시하지 않는다. 실패한 Auth signOut에도 로컬 코드와 기존 공유 알림은 지워진다. + +공유 코드 저장은 시작 시 UID/코드/세션 revision을 기억한다. 등록/해제의 서버 응답을 기다리는 동안 로그아웃·재로그인 또는 코드 변경이 발생하면 늦은 성공이 로컬 설정을 되살리거나 새 설정을 지우지 않는다. 서버 응답은 잠금 밖에서 기다린다. + +알림 식별자는 `shared_schedule:` 접두어와 UID·코드·일정 ID로 만든 문자열 tag를 사용한다. 같은 일정의 갱신은 같은 알림을 교체하고 서로 다른 일정은 정수 hash 충돌로 덮어쓰지 않는다. 세션 변경은 다음 알림만 취소한다. + +- 새 공유 tag 접두어의 알림. +- 기존 앱의 무tag ID 0 + `schedule_channel` 알림. +- 기존 Firebase SDK의 `FCM-Notification:` tag + ID 0 + `fcm_fallback_notification_channel` 알림. 현재 앱 manifest와 구 sender는 기본 채널/tag를 별도로 지정하지 않았다. + +정시 알람·놓친 알람을 이 관리자에서 `cancelAll`로 지우지 않는다. 로그아웃 자체의 기존 AlarmScheduler 예약 취소 정책은 유지된다. + +## 토큰 갱신과 여러 기기 + +`FCMService.onNewToken`은 잠금 안에서 현재 UID/코드를 얻어 `UserRepository.updateFcmRegistration`으로 사용자 토큰과 현재 watcher 토큰을 갱신한다. 서버 응답을 기다리지는 않는다. 실패 시 다음 로그인/목록 구독의 기존 등록 경로가 다시 시도한다. 토큰 조회를 await한 기존 등록 경로도 UID가 바뀌었는지 다시 확인한다. + +로그아웃에서 원격 UID watcher를 삭제하거나 FCM token을 폐기하지 않는다. UID별 한 문서는 다른 기기의 마지막 토큰을 가질 수 있기 때문이다. 현재의 마지막 등록 기기 우선 구조는 유지하며 설치별/FID 등록과 모든 기기 동시 수신 보장은 #84 후속이다. 잔류 토큰에 메시지가 도착할 수 있으나 새 앱의 현재 UID/코드가 맞지 않으면 표시하지 않는다. + +## 출시 전 전환 순서와 한계 + +1. 서버와 앱 변경을 함께 검증한다. 아래 자동 검증은 실제 운영 토큰에 알림을 보내지 않는다. +2. [Firestore 전환 절차](../firestore.md)에 따라 기존 데이터·권한 inventory를 확인하고 새 rules를 배포한다. 새 rules와 data-only Function 배포를 모두 완료하는 것이 새 앱 출시의 선행 조건이다. #102의 Firebase 운영 배포 선행조건은 별개이며 이 PR이 배포나 과금 설정을 수행하지 않는다. +3. 선행 배포를 확인한 뒤 새 앱을 출시하고, 테스터의 업데이트 여부와 background 수신·코드 변경·로그아웃 후 미표시를 검증한다. 새 sender + 새 receiver 조합에서만 보장한다. +4. 전환 전 큐에 남은 legacy notification 메시지는 OS가 자동 표시할 수 있다. 서버 전환으로 그 큐가 소급 삭제되지 않는다. 이 경계와 구버전 테스터 잔존 여부를 출시 판정에 기록한다. + +새 앱 + 기존 sender는 background OS 표시를 막을 수 없고, 기존 앱 + 새 sender도 기존 앱의 무조건 기본 알림 경로 때문에 로그아웃 미표시를 보장하지 않는다. 구버전 호환이 필요하면 별도 capability/전환 정책이 필요하다. 현재 릴리스 전환은 구앱까지의 보장을 주장하지 않는다. 이미 표시된 legacy 알림은 다음 세션 변경 때 좁게 정리하지만 그 뒤 도착한 legacy 큐까지 차단하는 것은 아니다. + +## 검증 + +- `node --test .github/scripts/functions-notification.test.mjs`: 실제 index.js를 fake Admin SDK에 로드하여 모든 이벤트, 삭제 before, UID 위조 필드, 동일 token/다른 UID, 1001개 분할, 부분/전체 실패, 민감 내용 로그 부재를 검사한다. +- `:core:alarm:testDebugUnitTest`: 실제 세션 잠금에서 latch를 사용한 notify/로그아웃·코드 교체 경합, 세션 revision, 메시지 거절, 권한/채널 차단, tag 충돌, 좁은 취소 대상, 실제 FCMService callback 분기를 검사한다. +- `:core:data:testDebugUnitTest`: 사용자/현재 watcher 토큰 동시 갱신, 빈 토큰/로그아웃 거절, token await 도중 계정 변경을 검사한다. +- `:feature:main:testDebugUnitTest`, `:feature:profile:testDebugUnitTest`: 늦은 등록/해제 응답과 세션 정리 호출을 검사한다. + +공식 근거: [Android 수신 경로](https://firebase.google.com/docs/cloud-messaging/android/receive-messages), [Admin SDK sendEach](https://firebase.google.com/docs/cloud-messaging/send/admin-sdk), [메시지 우선순위](https://firebase.google.com/docs/cloud-messaging/android-message-priority), [토큰 관리](https://firebase.google.com/docs/cloud-messaging/manage-tokens), [Firebase SDK 기본 tag/채널](https://github.com/firebase/firebase-android-sdk/blob/master/firebase-messaging/src/main/java/com/google/firebase/messaging/CommonNotificationBuilder.java). + + +### Android 실제 알림 검증 + +`SharedScheduleNotificationTest`는 별도 `demo-slowclock-fcm` FirebaseApp과 테스트 설정 파일만 사용한다. `sharedAuthEmulatorPort` 인자가 없으면 명시적으로 skip하므로 일반 기기 실행을 통과 증거로 세지 않는다. 운영 계정이 없는 폐기 가능한 Android emulator에서 실행한다. 테스트는 현재 앱의 공유 알림을 지우므로 개인 기기에서 실행하지 않는다. + +1. 별도 폴더의 `firebase.json`에 `{"emulators":{"auth":{"port":9096},"ui":{"enabled":false}}}`를 저장하고 `firebase emulators:start --only auth --project demo-slowclock-fcm --config firebase.json`을 실행한다. +2. `:app:assembleDebug :app:assembleDebugAndroidTest`로 만든 두 APK를 전용 emulator에 `adb -s install -r -g`로 설치한다. +3. 아래 세 메서드를 `adb -s shell am instrument -w -r -e sharedAuthEmulatorPort 9096 -e class # com.ilhyok.slowclock.test/androidx.test.runner.AndroidJUnitRunner`로 실행한다. class는 `com.example.slowclock.notification.SharedScheduleNotificationTest`다. 앱 알림을 허용한 상태에서 실행한다. + - `currentSessionOnlyAndDistinctScheduleIds` + - `logoutClearsNewAndLegacySharedNotificationsButKeepsAlarm` + - `codeReplacementAndUnregisterRemovePreviousNotifications` +4. `adb -s shell pm revoke com.ilhyok.slowclock android.permission.POST_NOTIFICATIONS` 후 같은 runner에 `-e sharedPermissionDenied true`를 추가해 `permissionDeniedDoesNotPost`를 실행한다. 끝나면 `pm grant`로 권한을 복구하고 전용 emulator/Auth emulator를 종료한다. + +Auth emulator용 HTTP 허용은 debug variant의 10.0.2.2/127.0.0.1/localhost에만 한정한다. release에는 해당 manifest/config가 포함되지 않는다. [Auth emulator 연결](https://firebase.google.com/docs/emulator-suite/connect_auth)과 [domain별 network security config](https://developer.android.com/privacy-and-security/security-config)를 따른다. 실제 OS NotificationManager, 알림 권한, UID/설정 검증을 사용하며 FCM 네트워크 발송 성공이나 운영 배포 검증을 대신하지 않는다. + + +### 계정 삭제 중 세션 전환 + +수신 경로 점검 중 계정 삭제가 비동기 단계 이후 현재 코드/UID를 다시 골라 새 계정의 watcher 또는 Auth 사용자를 지울 수 있는 경로도 확인했다. 삭제는 시작 시 UID·코드·revision을 고정하고 각 await 뒤 `ensureActive`와 동일 세션을 확인한다. 로컬 알람 취소도 동일 잠금에서 현재 세션일 때만 실행한다. watcher 해제와 Auth 삭제 Repository는 명시한 expected UID가 현재 사용자와 일치해야 시작하고, Auth는 확인한 FirebaseUser 인스턴스를 사용한다. + +6개 삭제 단계 각각에서 계정 전환 또는 취소가 일어나도 다음 파괴 단계로 진행하지 않는 테스트를 둔다. 이미 제출된 Firebase 작업은 coroutine 대기 취소만으로 철회됐다고 주장하지 않는다. Auth 단계에 도달했다면 원격 데이터와 watcher를 이미 제거했으므로 Auth 실패/취소 때도 그 세션의 로컬 코드·공유 알림을 finally에서 정리한다. 새 세션이 시작된 경우에는 revision/UID 검사로 이 정리가 새 설정을 건드리지 않는다. [협력적 취소 확인](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/ensure-active.html)을 따른다. diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt index 81f04edc..1215a185 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/ShareCodeViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.viewModelScope import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.notification.SharedScheduleNotifier import com.example.slowclock.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -17,6 +18,7 @@ class ShareCodeViewModel constructor( private val settingsRepository: SettingsRepository, private val userRepository: UserRepository, + private val sharedScheduleNotifier: SharedScheduleNotifier, ) : MviViewModel(ShareCodeUiState()) { init { dispatch(ShareCodeReducerEvent.Initialized(settingsRepository.getShareCode().orEmpty().uppercase(Locale.ROOT))) @@ -77,12 +79,18 @@ class ShareCodeViewModel if (!currentState.canSave) return val shareCode = currentState.input.trim().uppercase(Locale.ROOT) // 바꾸기 전 코드. 등록을 지우지 않으면 그 사람에게 내 토큰이 계속 남는다(#124). - val previousShareCode = settingsRepository.getShareCode() + val session = sharedScheduleNotifier.snapshot() + val previousShareCode = session.shareCode + val uid = session.userId + if (uid.isNullOrBlank()) { + dispatch(ShareCodeReducerEvent.SaveFailed("로그인한 뒤 공유 설정을 다시 확인해주세요")) + return + } dispatch(ShareCodeReducerEvent.Saving) viewModelScope.launch { if (shareCode.isBlank()) { if (!previousShareCode.isNullOrBlank() && - !userRepository.unregisterShareCodeWatcher(previousShareCode) + !userRepository.unregisterShareCodeWatcher(previousShareCode, uid) ) { dispatch( ShareCodeReducerEvent.SaveFailed( @@ -91,7 +99,10 @@ class ShareCodeViewModel ) return@launch } - settingsRepository.clearShareCode() + if (!sharedScheduleNotifier.replaceShareCode(session, null)) { + dispatch(ShareCodeReducerEvent.SaveFailed("로그인 또는 공유 설정이 변경되었습니다. 다시 확인해주세요")) + return@launch + } dispatch(ShareCodeReducerEvent.Saved) return@launch } @@ -100,15 +111,22 @@ class ShareCodeViewModel // 저장한다. 실패하면 앞 코드도 그대로 둔다 — 새 코드가 안 됐는데 앞의 것까지 // 잃으면 되돌아갈 자리가 없다(#174). if (!userRepository.registerShareCodeWatcher(shareCode)) { - Log.w("ShareCodeWatcher", "watcher register failed shareCode=$shareCode") + Log.w("ShareCodeWatcher", "공유 감시자 등록 실패") dispatch(ShareCodeReducerEvent.SaveFailed()) return@launch } + if (sharedScheduleNotifier.snapshot() != session) { + dispatch(ShareCodeReducerEvent.SaveFailed("로그인 또는 공유 설정이 변경되었습니다. 다시 확인해주세요")) + return@launch + } if (!previousShareCode.isNullOrBlank() && previousShareCode != shareCode) { - val unregistered = userRepository.unregisterShareCodeWatcher(previousShareCode) - Log.d("ShareCodeWatcher", "watcher unregistered=$unregistered shareCode=$previousShareCode") + val unregistered = userRepository.unregisterShareCodeWatcher(previousShareCode, uid) + Log.d("ShareCodeWatcher", "공유 감시자 해제 결과: $unregistered") + } + if (!sharedScheduleNotifier.replaceShareCode(session, shareCode)) { + dispatch(ShareCodeReducerEvent.SaveFailed("로그인 또는 공유 설정이 변경되었습니다. 다시 확인해주세요")) + return@launch } - settingsRepository.setShareCode(shareCode) dispatch(ShareCodeReducerEvent.Saved) } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt index fae767dc..40b6fddc 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/settings/ShareCodeViewModelTest.kt @@ -1,7 +1,11 @@ package com.example.slowclock.ui.settings +import android.app.NotificationManager +import android.content.Context +import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.notification.SharedScheduleNotifier import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -29,15 +33,23 @@ import java.util.Locale class ShareCodeViewModelTest { private val settingsRepository = mockk() private val userRepository = mockk() + private val authRepository = mockk() + private val manager = mockk(relaxed = true) + private val context = mockk() + private lateinit var notifier: SharedScheduleNotifier @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) + every { authRepository.currentUid } returns "uid-1" + every { context.getSystemService(NotificationManager::class.java) } returns manager + every { manager.activeNotifications } returns emptyArray() + notifier = SharedScheduleNotifier(context, authRepository, settingsRepository) every { settingsRepository.getShareCode() } returns "OLD001" justRun { settingsRepository.setShareCode(any()) } justRun { settingsRepository.clearShareCode() } coEvery { userRepository.registerShareCodeWatcher(any()) } returns true - coEvery { userRepository.unregisterShareCodeWatcher(any()) } returns true + coEvery { userRepository.unregisterShareCodeWatcher(any(), any()) } returns true } @After @@ -48,13 +60,13 @@ class ShareCodeViewModelTest { @Test fun `저장된 공유 코드로 입력을 채운다`() = runTest { - assertEquals("OLD001", ShareCodeViewModel(settingsRepository, userRepository).uiState.value.input) + assertEquals("OLD001", ShareCodeViewModel(settingsRepository, userRepository, notifier).uiState.value.input) } @Test fun `저장하면 설정에 쓰고 감시자를 등록한 뒤 저장 신호를 낸다`() = runTest { - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput(" NEW002 ")) viewModel.onIntent(ShareCodeIntent.Save) @@ -62,7 +74,7 @@ class ShareCodeViewModelTest { verify(exactly = 1) { settingsRepository.setShareCode("NEW002") } coVerify(exactly = 1) { userRepository.registerShareCodeWatcher("NEW002") } // 이전 코드의 등록을 지우지 않으면 그 사람에게 내 토큰이 계속 남는다(#124). - coVerify(exactly = 1) { userRepository.unregisterShareCodeWatcher("OLD001") } + coVerify(exactly = 1) { userRepository.unregisterShareCodeWatcher("OLD001", "uid-1") } assertTrue(viewModel.uiState.value.isSaved) assertFalse(viewModel.uiState.value.isSaving) @@ -76,13 +88,13 @@ class ShareCodeViewModelTest { // 등록이 곧 공유 일정을 읽을 권한이다. 등록 못 한 코드를 저장하면 가족 일정이 빈 채로 // 남고, 앞 코드까지 지우면 되돌아갈 자리도 없다(#174). coEvery { userRepository.registerShareCodeWatcher("NEW002") } returns false - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) viewModel.onIntent(ShareCodeIntent.Save) verify(exactly = 0) { settingsRepository.setShareCode(any()) } - coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any(), any()) } assertFalse(viewModel.uiState.value.isSaved) assertFalse(viewModel.uiState.value.isSaving) assertNotNull(viewModel.uiState.value.saveError) @@ -92,7 +104,7 @@ class ShareCodeViewModelTest { fun `입력을 고치면 실패 안내가 사라진다`() = runTest { coEvery { userRepository.registerShareCodeWatcher(any()) } returns false - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) viewModel.onIntent(ShareCodeIntent.Save) @@ -105,8 +117,8 @@ class ShareCodeViewModelTest { fun `빈 입력을 저장하면 감시자 해제 뒤 로컬 코드도 지운다`() = runTest { val unregistered = CompletableDeferred() - coEvery { userRepository.unregisterShareCodeWatcher("OLD001") } coAnswers { unregistered.await() } - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + coEvery { userRepository.unregisterShareCodeWatcher("OLD001", "uid-1") } coAnswers { unregistered.await() } + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput(" ")) viewModel.onIntent(ShareCodeIntent.Save) @@ -124,8 +136,8 @@ class ShareCodeViewModelTest { @Test fun `감시자 해제 실패는 기존 코드를 유지하고 다시 시도할 수 있다`() = runTest { - coEvery { userRepository.unregisterShareCodeWatcher("OLD001") } returns false - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + coEvery { userRepository.unregisterShareCodeWatcher("OLD001", "uid-1") } returns false + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("")) viewModel.onIntent(ShareCodeIntent.Save) @@ -135,7 +147,7 @@ class ShareCodeViewModelTest { assertNotNull(viewModel.uiState.value.saveError) assertFalse(viewModel.uiState.value.isSaved) - coEvery { userRepository.unregisterShareCodeWatcher("OLD001") } returns true + coEvery { userRepository.unregisterShareCodeWatcher("OLD001", "uid-1") } returns true viewModel.onIntent(ShareCodeIntent.Save) verify(exactly = 1) { settingsRepository.clearShareCode() } assertTrue(viewModel.uiState.value.isSaved) @@ -145,7 +157,7 @@ class ShareCodeViewModelTest { fun `저장된 코드가 없으면 빈 입력은 저장하지 않는다`() = runTest { every { settingsRepository.getShareCode() } returns null - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.Save) assertFalse(viewModel.uiState.value.canSave) verify(exactly = 0) { settingsRepository.clearShareCode() } @@ -157,7 +169,7 @@ class ShareCodeViewModelTest { val previousLocale = Locale.getDefault() try { Locale.setDefault(Locale.forLanguageTag("tr-TR")) - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput(" ai123b ")) assertEquals(" AI123B ", viewModel.uiState.value.input) viewModel.onIntent(ShareCodeIntent.Save) @@ -173,7 +185,7 @@ class ShareCodeViewModelTest { runTest { val registered = CompletableDeferred() coEvery { userRepository.registerShareCodeWatcher("NEW002") } coAnswers { registered.await() } - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) viewModel.onIntent(ShareCodeIntent.Save) viewModel.onIntent(ShareCodeIntent.UpdateInput("")) @@ -187,12 +199,12 @@ class ShareCodeViewModelTest { @Test fun `같은 코드를 다시 저장하면 해제하지 않는다`() = runTest { - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("OLD001")) viewModel.onIntent(ShareCodeIntent.Save) - coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any(), any()) } coVerify(exactly = 1) { userRepository.registerShareCodeWatcher("OLD001") } } @@ -200,12 +212,43 @@ class ShareCodeViewModelTest { fun `이전 코드가 없으면 해제하지 않는다`() = runTest { every { settingsRepository.getShareCode() } returns null - val viewModel = ShareCodeViewModel(settingsRepository, userRepository) + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) viewModel.onIntent(ShareCodeIntent.Save) - coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any(), any()) } coVerify(exactly = 1) { userRepository.registerShareCodeWatcher("NEW002") } } + + @Test + fun `감시자 등록 대기 중 로그아웃하면 늦은 성공이 코드를 되살리지 않는다`() = + runTest { + val registered = CompletableDeferred() + coEvery { userRepository.registerShareCodeWatcher("NEW002") } coAnswers { registered.await() } + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) + viewModel.onIntent(ShareCodeIntent.UpdateInput("NEW002")) + viewModel.onIntent(ShareCodeIntent.Save) + notifier.changeSession { every { authRepository.currentUid } returns null } + registered.complete(true) + verify(exactly = 0) { settingsRepository.setShareCode(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any(), any()) } + assertNotNull(viewModel.uiState.value.saveError) + assertFalse(viewModel.uiState.value.isSaved) + } + + @Test + fun `해제 대기 중 새 세션이 코드를 저장하면 늦은 해제가 새 코드를 지우지 않는다`() = + runTest { + val unregistered = CompletableDeferred() + coEvery { userRepository.unregisterShareCodeWatcher("OLD001", "uid-1") } coAnswers { unregistered.await() } + val viewModel = ShareCodeViewModel(settingsRepository, userRepository, notifier) + viewModel.onIntent(ShareCodeIntent.UpdateInput("")) + viewModel.onIntent(ShareCodeIntent.Save) + notifier.changeSession { every { settingsRepository.getShareCode() } returns "NEW002" } + unregistered.complete(true) + verify(exactly = 0) { settingsRepository.clearShareCode() } + assertEquals("NEW002", settingsRepository.getShareCode()) + assertFalse(viewModel.uiState.value.isSaved) + } } diff --git a/feature/profile/src/main/java/com/example/slowclock/domain/profile/DeleteAccountUseCase.kt b/feature/profile/src/main/java/com/example/slowclock/domain/profile/DeleteAccountUseCase.kt index d0d27c0a..93e4fafe 100644 --- a/feature/profile/src/main/java/com/example/slowclock/domain/profile/DeleteAccountUseCase.kt +++ b/feature/profile/src/main/java/com/example/slowclock/domain/profile/DeleteAccountUseCase.kt @@ -5,8 +5,10 @@ import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.FamilyGroupRepository import com.example.slowclock.data.remote.repository.NotificationRepository import com.example.slowclock.data.remote.repository.ScheduleRepository -import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.notification.SharedScheduleNotifier +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import javax.inject.Inject /** 계정 삭제 중 실패한 단계. 사용자 안내와 재시도 판단에 쓴다. */ @@ -47,41 +49,62 @@ class DeleteAccountUseCase private val familyGroupRepository: FamilyGroupRepository, private val notificationRepository: NotificationRepository, private val userRepository: UserRepository, - private val settingsRepository: SettingsRepository, private val alarmScheduler: AlarmScheduler, + private val sharedScheduleNotifier: SharedScheduleNotifier, ) { suspend operator fun invoke(): DeleteAccountResult { - val uid = authRepository.currentUid ?: return DeleteAccountResult.NotSignedIn + currentCoroutineContext().ensureActive() + val session = sharedScheduleNotifier.snapshot() + val uid = + session.userId ?: run { + sharedScheduleNotifier.clearDeletedAccount(session) + return DeleteAccountResult.NotSignedIn + } - if (!scheduleRepository.deleteAllSchedulesOf(uid)) { - return DeleteAccountResult.Failed(DeleteAccountStep.SCHEDULES) + suspend fun sessionIsCurrent(): Boolean { + currentCoroutineContext().ensureActive() + return sharedScheduleNotifier.snapshot() == session && authRepository.currentUid == uid } - // 서버 일정이 사라지면 이 기기에 걸린 알람은 근거를 잃는다. 지우지 않으면 계정을 - // 지운 뒤에도 울리고, 재부팅 뒤에도 장부를 보고 되살아난다(#127). - alarmScheduler.cancelAll() - if (!familyGroupRepository.leaveAllGroupsOf(uid)) { - return DeleteAccountResult.Failed(DeleteAccountStep.FAMILY_GROUPS) - } - if (!notificationRepository.deleteAllNotificationsOf(uid)) { - return DeleteAccountResult.Failed(DeleteAccountStep.NOTIFICATIONS) - } - // 가족의 공유 코드에 걸어 둔 내 감시자 등록. 보안 규칙이 본인만 지우게 하므로 - // Auth 사용자가 사라진 뒤에는 아무도 못 지운다. 반드시 여기서 먼저 지운다(#124). - val watchedShareCode = settingsRepository.getShareCode() - if (!watchedShareCode.isNullOrBlank() && - !userRepository.unregisterShareCodeWatcher(watchedShareCode) - ) { - return DeleteAccountResult.Failed(DeleteAccountStep.SHARE_CODE_WATCHERS) - } - if (!userRepository.deleteUserDocument(uid)) { - return DeleteAccountResult.Failed(DeleteAccountStep.USER_DOCUMENT) + + val schedulesDeleted = scheduleRepository.deleteAllSchedulesOf(uid) + if (!sessionIsCurrent()) return DeleteAccountResult.NotSignedIn + if (!schedulesDeleted) return DeleteAccountResult.Failed(DeleteAccountStep.SCHEDULES) + // 앞 계정의 서버 응답이 새 계정의 기기 알람을 취소하지 않게 같은 세션 잠금에서 처리한다. + if (!sharedScheduleNotifier.runIfCurrent(session) { alarmScheduler.cancelAll() }) return DeleteAccountResult.NotSignedIn + + val groupsLeft = familyGroupRepository.leaveAllGroupsOf(uid) + if (!sessionIsCurrent()) return DeleteAccountResult.NotSignedIn + if (!groupsLeft) return DeleteAccountResult.Failed(DeleteAccountStep.FAMILY_GROUPS) + + val notificationsDeleted = notificationRepository.deleteAllNotificationsOf(uid) + if (!sessionIsCurrent()) return DeleteAccountResult.NotSignedIn + if (!notificationsDeleted) return DeleteAccountResult.Failed(DeleteAccountStep.NOTIFICATIONS) + + // 늦은 콜백에서 현재 설정/현재 UID를 다시 선택하지 않는다. + val watchedShareCode = session.shareCode + if (!watchedShareCode.isNullOrBlank()) { + val unregistered = userRepository.unregisterShareCodeWatcher(watchedShareCode, uid) + if (!sessionIsCurrent()) return DeleteAccountResult.NotSignedIn + if (!unregistered) return DeleteAccountResult.Failed(DeleteAccountStep.SHARE_CODE_WATCHERS) } + val userDeleted = userRepository.deleteUserDocument(uid) + if (!sessionIsCurrent()) return DeleteAccountResult.NotSignedIn + if (!userDeleted) return DeleteAccountResult.Failed(DeleteAccountStep.USER_DOCUMENT) - return when (authRepository.deleteCurrentUser()) { - AuthRepository.DeleteResult.Success -> DeleteAccountResult.Success - AuthRepository.DeleteResult.NotSignedIn -> DeleteAccountResult.NotSignedIn - AuthRepository.DeleteResult.RecentLoginRequired -> DeleteAccountResult.RecentLoginRequired - is AuthRepository.DeleteResult.Failure -> DeleteAccountResult.Failed(DeleteAccountStep.AUTH_USER) + try { + val authResult = authRepository.deleteCurrentUser(uid) + currentCoroutineContext().ensureActive() + val current = sharedScheduleNotifier.snapshot() + if (current != session && current != session.copy(userId = null)) return DeleteAccountResult.NotSignedIn + return when (authResult) { + AuthRepository.DeleteResult.Success -> DeleteAccountResult.Success + AuthRepository.DeleteResult.NotSignedIn -> DeleteAccountResult.NotSignedIn + AuthRepository.DeleteResult.RecentLoginRequired -> DeleteAccountResult.RecentLoginRequired + is AuthRepository.DeleteResult.Failure -> DeleteAccountResult.Failed(DeleteAccountStep.AUTH_USER) + } + } finally { + // 원격 데이터/감시자를 이미 지웠다. Auth 대기 취소/실패에도 이 세션의 로컬 공유 흔적은 정리한다. + sharedScheduleNotifier.clearDeletedAccount(session) } } } diff --git a/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt b/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt index 121f3f3e..1ea03e6f 100644 --- a/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt +++ b/feature/profile/src/main/java/com/example/slowclock/domain/profile/SignOutUseCase.kt @@ -3,6 +3,7 @@ package com.example.slowclock.domain.profile import com.example.slowclock.core.alarm.AlarmScheduler import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.SettingsRepository +import com.example.slowclock.notification.SharedScheduleNotifier import javax.inject.Inject /** @@ -24,12 +25,15 @@ class SignOutUseCase private val authRepository: AuthRepository, private val alarmScheduler: AlarmScheduler, private val settingsRepository: SettingsRepository, + private val sharedScheduleNotifier: SharedScheduleNotifier, ) { operator fun invoke() { // 세션을 끊기 전에 기기 잔재부터 지운다. 순서가 반대면 중간에 실패했을 때 // 로그인은 풀렸는데 알람만 남는 상태가 된다. alarmScheduler.cancelAll() - settingsRepository.clearShareCode() - authRepository.signOut() + sharedScheduleNotifier.changeSession { + settingsRepository.clearShareCode() + authRepository.signOut() + } } } diff --git a/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountSessionTest.kt b/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountSessionTest.kt new file mode 100644 index 00000000..25c92154 --- /dev/null +++ b/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountSessionTest.kt @@ -0,0 +1,133 @@ +package com.example.slowclock.domain.profile + +import android.app.NotificationManager +import android.content.Context +import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.data.remote.repository.AuthRepository +import com.example.slowclock.data.remote.repository.FamilyGroupRepository +import com.example.slowclock.data.remote.repository.NotificationRepository +import com.example.slowclock.data.remote.repository.ScheduleRepository +import com.example.slowclock.data.remote.repository.SettingsRepository +import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.notification.SharedScheduleNotifier +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(Parameterized::class) +class DeleteAccountSessionTest( + private val pausedStep: Int, +) { + private var uid: String? = "old-uid" + private var code: String? = "OLD001" + private val gate = CompletableDeferred() + private val auth = mockk() + private val schedules = mockk() + private val groups = mockk() + private val notifications = mockk() + private val users = mockk() + private val settings = mockk() + private val alarms = mockk(relaxed = true) + private val context = mockk() + private val manager = mockk() + private val notifier: SharedScheduleNotifier + private val delete: DeleteAccountUseCase + private val visited = mutableListOf() + + init { + every { auth.currentUid } answers { uid } + every { settings.getShareCode() } answers { code } + every { settings.clearShareCode() } answers { code = null } + every { context.getSystemService(NotificationManager::class.java) } returns manager + every { manager.activeNotifications } returns emptyArray() + notifier = SharedScheduleNotifier(context, auth, settings) + coEvery { schedules.deleteAllSchedulesOf("old-uid") } coAnswers { + pauseAt(0) + true + } + coEvery { groups.leaveAllGroupsOf("old-uid") } coAnswers { + pauseAt(1) + true + } + coEvery { notifications.deleteAllNotificationsOf("old-uid") } coAnswers { + pauseAt(2) + true + } + coEvery { users.unregisterShareCodeWatcher("OLD001", "old-uid") } coAnswers { + pauseAt(3) + true + } + coEvery { users.deleteUserDocument("old-uid") } coAnswers { + pauseAt(4) + true + } + coEvery { auth.deleteCurrentUser("old-uid") } coAnswers { + pauseAt(5) + AuthRepository.DeleteResult.Success + } + delete = DeleteAccountUseCase(auth, schedules, groups, notifications, users, alarms, notifier) + } + + private suspend fun pauseAt(step: Int) { + visited.add(step) + if (pausedStep == step) { + try { + gate.await() + } catch (_: CancellationException) { + // 기존 저장소가 취소를 Boolean으로 바꾸더라도 UseCase의 다음 파괴 단계를 막아야 한다. + } + } + } + + @Test + fun `각 단계의 늦은 완료는 새 계정으로 삭제를 이어가지 않는다`() = + runTest { + val result = async { delete() } + runCurrent() + assertEquals(pausedStep, visited.last()) + notifier.changeSession { + uid = "new-uid" + code = "NEW002" + } + gate.complete(Unit) + assertEquals(DeleteAccountResult.NotSignedIn, result.await()) + assertEquals((0..pausedStep).toList(), visited) + assertEquals("NEW002", code) + coVerify(exactly = 0) { users.unregisterShareCodeWatcher(any(), "new-uid") } + coVerify(exactly = 0) { auth.deleteCurrentUser("new-uid") } + if (pausedStep == 0) verify(exactly = 0) { alarms.cancelAll() } + } + + @Test + fun `각 단계의 취소가 일반 결과로 돌아와도 다음 삭제를 하지 않는다`() = + runTest { + val result = async { delete() } + runCurrent() + assertEquals(pausedStep, visited.last()) + result.cancel() + runCurrent() + assertTrue(result.isCancelled) + assertEquals((0..pausedStep).toList(), visited) + if (pausedStep == 0) verify(exactly = 0) { alarms.cancelAll() } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "pause after deletion step {0}") + fun steps(): List> = (0..5).map { arrayOf(it) } + } +} diff --git a/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountUseCaseTest.kt b/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountUseCaseTest.kt index 5382ac79..596ba957 100644 --- a/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountUseCaseTest.kt +++ b/feature/profile/src/test/java/com/example/slowclock/domain/profile/DeleteAccountUseCaseTest.kt @@ -7,6 +7,8 @@ import com.example.slowclock.data.remote.repository.NotificationRepository import com.example.slowclock.data.remote.repository.ScheduleRepository import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository +import com.example.slowclock.notification.SharedNotificationSession +import com.example.slowclock.notification.SharedScheduleNotifier import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder @@ -23,7 +25,19 @@ class DeleteAccountUseCaseTest { private val familyGroupRepository = mockk() private val notificationRepository = mockk() private val userRepository = mockk() - private val settingsRepository = mockk() + private val settingsRepository = mockk(relaxed = true) + private val notifier = + mockk { + every { snapshot() } answers { SharedNotificationSession(authRepository.currentUid, settingsRepository.getShareCode(), 0) } + every { runIfCurrent(any(), any()) } answers { + secondArg<() -> Unit>().invoke() + true + } + every { clearDeletedAccount(any()) } answers { + settingsRepository.clearShareCode() + true + } + } private val alarmScheduler = mockk(relaxed = true) private val useCase = @@ -33,8 +47,8 @@ class DeleteAccountUseCaseTest { familyGroupRepository = familyGroupRepository, notificationRepository = notificationRepository, userRepository = userRepository, - settingsRepository = settingsRepository, alarmScheduler = alarmScheduler, + sharedScheduleNotifier = notifier, ) private fun givenSignedIn(uid: String = "uid-1") { @@ -47,7 +61,7 @@ class DeleteAccountUseCaseTest { coEvery { notificationRepository.deleteAllNotificationsOf(uid) } returns true coEvery { userRepository.deleteUserDocument(uid) } returns true every { settingsRepository.getShareCode() } returns "FAM001" - coEvery { userRepository.unregisterShareCodeWatcher(any()) } returns true + coEvery { userRepository.unregisterShareCodeWatcher(any(), any()) } returns true } @Test @@ -55,18 +69,22 @@ class DeleteAccountUseCaseTest { runTest { givenSignedIn() givenDataDeletionSucceeds() - coEvery { authRepository.deleteCurrentUser() } returns AuthRepository.DeleteResult.Success + coEvery { authRepository.deleteCurrentUser("uid-1") } returns AuthRepository.DeleteResult.Success val result = useCase() assertEquals(DeleteAccountResult.Success, result) + verify(exactly = 1) { + notifier.clearDeletedAccount(any()) + settingsRepository.clearShareCode() + } coVerifyOrder { scheduleRepository.deleteAllSchedulesOf("uid-1") familyGroupRepository.leaveAllGroupsOf("uid-1") notificationRepository.deleteAllNotificationsOf("uid-1") - userRepository.unregisterShareCodeWatcher("FAM001") + userRepository.unregisterShareCodeWatcher("FAM001", "uid-1") userRepository.deleteUserDocument("uid-1") - authRepository.deleteCurrentUser() + authRepository.deleteCurrentUser("uid-1") } } @@ -75,7 +93,7 @@ class DeleteAccountUseCaseTest { runTest { givenSignedIn() givenDataDeletionSucceeds() - coEvery { authRepository.deleteCurrentUser() } returns AuthRepository.DeleteResult.RecentLoginRequired + coEvery { authRepository.deleteCurrentUser("uid-1") } returns AuthRepository.DeleteResult.RecentLoginRequired assertEquals(DeleteAccountResult.RecentLoginRequired, useCase()) } @@ -91,7 +109,7 @@ class DeleteAccountUseCaseTest { assertEquals(DeleteAccountResult.Failed(DeleteAccountStep.SCHEDULES), result) coVerify(exactly = 0) { familyGroupRepository.leaveAllGroupsOf(any()) } coVerify(exactly = 0) { userRepository.deleteUserDocument(any()) } - coVerify(exactly = 0) { authRepository.deleteCurrentUser() } + coVerify(exactly = 0) { authRepository.deleteCurrentUser("uid-1") } } @Test @@ -104,7 +122,7 @@ class DeleteAccountUseCaseTest { val result = useCase() assertEquals(DeleteAccountResult.Failed(DeleteAccountStep.USER_DOCUMENT), result) - coVerify(exactly = 0) { authRepository.deleteCurrentUser() } + coVerify(exactly = 0) { authRepository.deleteCurrentUser("uid-1") } } @Test @@ -114,7 +132,7 @@ class DeleteAccountUseCaseTest { assertEquals(DeleteAccountResult.NotSignedIn, useCase()) coVerify(exactly = 0) { scheduleRepository.deleteAllSchedulesOf(any()) } - coVerify(exactly = 0) { authRepository.deleteCurrentUser() } + coVerify(exactly = 0) { authRepository.deleteCurrentUser("uid-1") } } @Test @@ -123,12 +141,12 @@ class DeleteAccountUseCaseTest { // 보안 규칙이 본인만 지우게 하므로 Auth 사용자가 사라진 뒤에는 아무도 못 지운다(#124). givenSignedIn() givenDataDeletionSucceeds() - coEvery { authRepository.deleteCurrentUser() } returns AuthRepository.DeleteResult.Success + coEvery { authRepository.deleteCurrentUser("uid-1") } returns AuthRepository.DeleteResult.Success useCase() coVerifyOrder { - userRepository.unregisterShareCodeWatcher("FAM001") + userRepository.unregisterShareCodeWatcher("FAM001", "uid-1") userRepository.deleteUserDocument("uid-1") } } @@ -138,13 +156,13 @@ class DeleteAccountUseCaseTest { runTest { givenSignedIn() givenDataDeletionSucceeds() - coEvery { userRepository.unregisterShareCodeWatcher("FAM001") } returns false + coEvery { userRepository.unregisterShareCodeWatcher("FAM001", "uid-1") } returns false val result = useCase() assertEquals(DeleteAccountResult.Failed(DeleteAccountStep.SHARE_CODE_WATCHERS), result) coVerify(exactly = 0) { userRepository.deleteUserDocument(any()) } - coVerify(exactly = 0) { authRepository.deleteCurrentUser() } + coVerify(exactly = 0) { authRepository.deleteCurrentUser("uid-1") } } @Test @@ -153,12 +171,12 @@ class DeleteAccountUseCaseTest { givenSignedIn() givenDataDeletionSucceeds() every { settingsRepository.getShareCode() } returns null - coEvery { authRepository.deleteCurrentUser() } returns AuthRepository.DeleteResult.Success + coEvery { authRepository.deleteCurrentUser("uid-1") } returns AuthRepository.DeleteResult.Success val result = useCase() assertEquals(DeleteAccountResult.Success, result) - coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any()) } + coVerify(exactly = 0) { userRepository.unregisterShareCodeWatcher(any(), any()) } } @Test @@ -168,7 +186,7 @@ class DeleteAccountUseCaseTest { // 되살아난다(#127). givenSignedIn() givenDataDeletionSucceeds() - coEvery { authRepository.deleteCurrentUser() } returns AuthRepository.DeleteResult.Success + coEvery { authRepository.deleteCurrentUser("uid-1") } returns AuthRepository.DeleteResult.Success useCase() diff --git a/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt b/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt index a4ea4e51..97baf133 100644 --- a/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt +++ b/feature/profile/src/test/java/com/example/slowclock/domain/profile/SignOutUseCaseTest.kt @@ -3,6 +3,8 @@ package com.example.slowclock.domain.profile import com.example.slowclock.core.alarm.AlarmScheduler import com.example.slowclock.data.remote.repository.AuthRepository import com.example.slowclock.data.remote.repository.SettingsRepository +import com.example.slowclock.notification.SharedScheduleNotifier +import io.mockk.every import io.mockk.mockk import io.mockk.verifyOrder import org.junit.Test @@ -18,7 +20,11 @@ class SignOutUseCaseTest { private val alarmScheduler = mockk(relaxed = true) private val settingsRepository = mockk(relaxed = true) - private val signOut = SignOutUseCase(authRepository, alarmScheduler, settingsRepository) + private val notifier = + mockk { + every { changeSession(any()) } answers { firstArg<() -> Unit>().invoke() } + } + private val signOut = SignOutUseCase(authRepository, alarmScheduler, settingsRepository, notifier) @Test fun `세션을 끊기 전에 기기 잔재를 먼저 지운다`() { @@ -27,6 +33,7 @@ class SignOutUseCaseTest { verifyOrder { alarmScheduler.cancelAll() + notifier.changeSession(any()) settingsRepository.clearShareCode() authRepository.signOut() } diff --git a/functions/index.js b/functions/index.js index 488a10a3..f36a81b5 100644 --- a/functions/index.js +++ b/functions/index.js @@ -11,7 +11,10 @@ exports.sendFcmToShareCodeWatchers = onDocumentWritten( const after = event.data.after.data(); // 삭제 이벤트에는 after 데이터가 없다. 공유 대상과 제목은 삭제 전 값에서 읽는다. const schedule = after || before; - if (!schedule || !schedule.sharedCode) return null; + if (!schedule || typeof schedule.sharedCode !== "string" || + !schedule.sharedCode.trim() || + !event.params || typeof event.params.scheduleId !== "string" || + !event.params.scheduleId.trim()) return null; const tokensSnapshot = await admin .firestore() @@ -19,13 +22,16 @@ exports.sendFcmToShareCodeWatchers = onDocumentWritten( .doc(schedule.sharedCode) .collection("tokens") .get(); - const uniqueTokens = new Set(); + const recipients = new Map(); tokensSnapshot.forEach((doc) => { const token = doc.data().fcmToken; - if (typeof token === "string" && token.trim()) uniqueTokens.add(token); + // 문서의 userId 필드는 소유자가 쓸 수 있다. 수신 UID는 규칙이 보호하는 경로에서 읽는다. + if (typeof doc.id === "string" && doc.id.trim() && + typeof token === "string" && token.trim()) { + recipients.set(JSON.stringify([doc.id, token]), {uid: doc.id, token}); + } }); - const tokens = [...uniqueTokens]; - if (tokens.length === 0) return null; + if (recipients.size === 0) return null; let notificationTitle = "일정이 변경되었습니다"; if (!event.data.before.exists) { @@ -37,18 +43,28 @@ exports.sendFcmToShareCodeWatchers = onDocumentWritten( "완료되었습니다" : "상태 미완료로 바꿨습니다"; } - // multicast는 호출마다 최대 500개다. 토큰이나 일정 내용을 로그에 남기지 않는다. + // data-only 메시지만 보내야 background에서도 앱이 현재 로그인/공유 코드를 검사한다. + // sendEach는 호출마다 최대 500개다. 서로 다른 UID의 동일 토큰은 별개 수신자다. // https://firebase.google.com/docs/cloud-messaging/send/admin-sdk + const messages = [...recipients.values()].map(({uid, token}) => ({ + token, + data: { + type: "shared_schedule", + schemaVersion: "1", + recipientUid: uid, + shareCode: schedule.sharedCode, + scheduleId: event.params.scheduleId, + title: notificationTitle, + body: typeof schedule.title === "string" ? schedule.title : "", + }, + android: {priority: "high"}, + })); let successCount = 0; let failureCount = 0; - for (let offset = 0; offset < tokens.length; offset += 500) { - const result = await admin.messaging().sendEachForMulticast({ - notification: { - title: notificationTitle, - body: typeof schedule.title === "string" ? schedule.title : "", - }, - tokens: tokens.slice(offset, offset + 500), - }); + for (let offset = 0; offset < messages.length; offset += 500) { + const result = await admin.messaging().sendEach( + messages.slice(offset, offset + 500), + ); successCount += result.successCount; failureCount += result.failureCount; }