diff --git a/Application/App/Sources/App/Assembler/AppLayerAssembler.swift b/Application/App/Sources/App/Assembler/AppLayerAssembler.swift index 66550973..96d235d0 100644 --- a/Application/App/Sources/App/Assembler/AppLayerAssembler.swift +++ b/Application/App/Sources/App/Assembler/AppLayerAssembler.swift @@ -19,6 +19,7 @@ final class AppLayerAssembler: Assembler { } container.register(UserTimeZoneSyncHandler.self) { UserTimeZoneSyncHandler( + authService: container.resolve(AuthService.self), userService: container.resolve(UserService.self) ) } diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index d8921649..57f4d279 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -38,6 +38,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { name: .didRequestRemoteNotificationRegistration, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(requestUserTimeZoneSync), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) // 알림 권한 요청 UNUserNotificationCenter.current().delegate = self @@ -77,6 +83,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { NotificationCenter.default.post(name: .didRequestAPNsRegistration, object: nil) } + @objc private func requestUserTimeZoneSync() { + NotificationCenter.default.post(name: .didRequestUserTimeZoneSync, object: nil) + } + // APNs 등록 성공 func application( _ application: UIApplication, diff --git a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift index a42af9dd..c92e6fbb 100644 --- a/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift +++ b/Application/App/Sources/App/Handler/UserTimeZoneSyncHandler.swift @@ -8,30 +8,79 @@ import Combine import Core import Data -import UIKit +import Foundation final class UserTimeZoneSyncHandler { + private struct SyncKey: Equatable { + let uid: String + let timeZoneIdentifier: String + } + + private let authService: AuthService private let userService: UserService private let logger = Logger(category: "UserTimeZoneSyncHandler") + private var lastSyncedKey: SyncKey? private var cancellables = Set() init( + authService: AuthService, userService: UserService, notificationCenter: NotificationCenter = .default ) { + self.authService = authService self.userService = userService + authService.observeSignedIn() + .sink { [weak self] isSignedIn in + self?.handleSessionUpdate(isSignedIn: isSignedIn) + } + .store(in: &cancellables) + notificationCenter.publisher(for: .didRequestUserTimeZoneSync) - .merge(with: notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)) .sink { [weak self] _ in - Task { - do { - try await self?.userService.updateUserTimeZone() - } catch { - self?.logger.error("Failed to sync user timeZone", error: error) - } - } + self?.requestUserTimeZoneSync() } .store(in: &cancellables) } } + +private extension UserTimeZoneSyncHandler { + func handleSessionUpdate(isSignedIn: Bool) { + guard isSignedIn else { + lastSyncedKey = nil + return + } + + requestUserTimeZoneSync() + } + + func requestUserTimeZoneSync() { + Task { [weak self] in + await self?.syncUserTimeZoneIfNeeded() + } + } + + func syncUserTimeZoneIfNeeded() async { + guard let uid = authService.uid else { + logger.info("Skipping timeZone update because no authenticated user exists") + return + } + + let key = SyncKey( + uid: uid, + timeZoneIdentifier: TimeZone.autoupdatingCurrent.identifier + ) + + guard lastSyncedKey != key else { + logger.info("Skipping timeZone update because the current user timeZone is already synced") + return + } + + do { + try await userService.updateUserTimeZone() + lastSyncedKey = key + } catch { + logger.error("Failed to sync user timeZone", error: error) + } + } +} diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift new file mode 100644 index 00000000..874816ce --- /dev/null +++ b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift @@ -0,0 +1,215 @@ +// +// UserTimeZoneSyncHandlerTests.swift +// AppTests +// +// Created by opfic on 7/3/26. +// + +import Combine +import Foundation +import Testing +import Data +@testable import App + +struct UserTimeZoneSyncHandlerTests { + @Test("로그인 세션 전이 시 현재 timeZone을 저장한다") + func 로그인_세션_전이_시_현재_timeZone을_저장한다() async throws { + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: nil) + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "user-id") + + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + _ = handler + } + + @Test("같은 사용자와 같은 timeZone은 중복 저장하지 않는다") + func 같은_사용자와_같은_timeZone은_중복_저장하지_않는다() async throws { + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + authService.updateSession(uid: "user-id") + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updateUserTimeZoneCallCount == 1) + _ = handler + } + + @Test("foreground 복귀 시 현재 timeZone을 요청하되 같은 사용자와 같은 timeZone은 중복 저장하지 않는다") + func foreground_복귀_시_현재_timeZone을_요청하되_같은_사용자와_같은_timeZone은_중복_저장하지_않는다() async throws { + let notificationCenter = NotificationCenter() + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestUserTimeZoneSync, object: nil) + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + notificationCenter.post(name: .didRequestUserTimeZoneSync, object: nil) + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updateUserTimeZoneCallCount == 1) + _ = handler + } + + @Test("같은 timeZone이어도 사용자가 바뀌면 다시 저장한다") + func 같은_timeZone이어도_사용자가_바뀌면_다시_저장한다() async throws { + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: "first-user") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "first-user") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + authService.updateSession(uid: "second-user") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 2 + } + _ = handler + } + + @Test("로그아웃 상태에서는 timeZone을 저장하지 않고 캐시를 초기화한다") + func 로그아웃_상태에서는_timeZone을_저장하지_않고_캐시를_초기화한다() async throws { + let userService = UserServiceSpy() + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + authService.updateSession(uid: nil) + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updateUserTimeZoneCallCount == 1) + + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 2 + } + _ = handler + } + + @Test("timeZone 저장 실패 시 캐시를 갱신하지 않는다") + func timeZone_저장_실패_시_캐시를_갱신하지_않는다() async throws { + let userService = UserServiceSpy(updateError: TestError.updateFailed) + let authService = AuthServiceSpy(uid: "user-id") + let handler = UserTimeZoneSyncHandler( + authService: authService, + userService: userService + ) + + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 1 + } + + await userService.setUpdateError(nil) + authService.updateSession(uid: "user-id") + try await waitUntil { + await userService.updateUserTimeZoneCallCount == 2 + } + _ = handler + } +} + +private actor UserServiceSpy: UserService { + private var updateError: Error? + private(set) var updateUserTimeZoneCallCount = 0 + + init(updateError: Error? = nil) { + self.updateError = updateError + } + + func upsertUser(_ response: AuthDataResponse) async throws { } + func fetchUserProfile() async throws -> UserProfileResponse { fatalError() } + func upsertStatusMessage(_ message: String) async throws { } + func updateFCMToken(_ fcmToken: String) async throws { } + + func updateUserTimeZone() async throws { + updateUserTimeZoneCallCount += 1 + if let updateError { + throw updateError + } + } + + func setUpdateError(_ updateError: Error?) { + self.updateError = updateError + } +} + +private final class AuthServiceSpy: AuthService { + var uid: String? + let providerIDs = [String]() + let currentUserEmail: String? = nil + let providerCount = 0 + private let subject = PassthroughSubject() + + init(uid: String? = "user-id") { + self.uid = uid + } + + func observeSignedIn() -> AnyPublisher { + subject.eraseToAnyPublisher() + } + + func updateSession(uid: String?) { + self.uid = uid + subject.send(uid != nil) + } + + func beginSignIn() { } + func completeSignIn() { } + func cancelSignIn() { } + func getProviderID() async throws -> String? { nil } + func deleteCurrentUser() async throws { } + func clearCurrentSession() async throws { } +} + +private enum TestError: Error { + case updateFailed +} + +private func waitUntil( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(10), + condition: @escaping @Sendable () async -> Bool +) async throws { + let deadline = ContinuousClock.now + timeout + + while ContinuousClock.now < deadline { + if await condition() { + return + } + try await Task.sleep(for: pollInterval) + } + + Issue.record("조건을 만족하지 못함") +}