From 1f630d34b5f3c95b292ec340408c7bab09e100c6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:50:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20Firebase=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...letePushNotificationIntegrationTests.swift | 51 --- .../Support/LocalFirebaseRESTSupport.swift | 376 ------------------ .../DeleteWebPageIntegrationTests.swift | 51 --- 3 files changed, 478 deletions(-) delete mode 100644 Application/App/Tests/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift delete mode 100644 Application/App/Tests/Support/LocalFirebaseRESTSupport.swift delete mode 100644 Application/App/Tests/WebPage/Integration/DeleteWebPageIntegrationTests.swift diff --git a/Application/App/Tests/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift b/Application/App/Tests/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift deleted file mode 100644 index 014c2b26..00000000 --- a/Application/App/Tests/PushNotification/Integration/DeletePushNotificationIntegrationTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DeletePushNotificationIntegrationTests.swift -// AppTests -// -// Created by opfic on 4/6/26. -// - -import Testing -import Foundation - -@Suite(.serialized) -struct DeletePushNotificationIntegrationTests { - @Test("푸시 알림 삭제를 되돌리면 목록에 보인다") - func 푸시_알림_삭제를_되돌리면_목록에_보인다() async throws { - let authSession = try await LocalFirebaseRESTSupport.shared.anonymousSignIn() - let notificationId = try await LocalFirebaseRESTSupport.shared.seedPushNotification( - userId: authSession.userId - ) - - try await LocalFirebaseRESTSupport.shared.requestPushNotificationDeletion( - notificationId: notificationId, - idToken: authSession.idToken - ) - - try await LocalFirebaseRESTSupport.shared.waitUntil { - let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( - userId: authSession.userId - ) - return !visibleNotificationIds.contains(notificationId) - } - - try await LocalFirebaseRESTSupport.shared.undoPushNotificationDeletion( - notificationId: notificationId, - idToken: authSession.idToken - ) - - try await LocalFirebaseRESTSupport.shared.waitUntil { - let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( - userId: authSession.userId - ) - return visibleNotificationIds.contains(notificationId) - } - - try await Task.sleep(for: .seconds(6)) - - let visibleNotificationIds = try await LocalFirebaseRESTSupport.shared.fetchPushNotificationIDs( - userId: authSession.userId - ) - #expect(visibleNotificationIds.contains(notificationId)) - } -} diff --git a/Application/App/Tests/Support/LocalFirebaseRESTSupport.swift b/Application/App/Tests/Support/LocalFirebaseRESTSupport.swift deleted file mode 100644 index 6d2bc7aa..00000000 --- a/Application/App/Tests/Support/LocalFirebaseRESTSupport.swift +++ /dev/null @@ -1,376 +0,0 @@ -// -// LocalFirebaseRESTSupport.swift -// AppTests -// -// Created by opfic on 4/6/26. -// - -import Foundation - -final class LocalFirebaseRESTSupport { - struct AuthSession { - let userId: String - let idToken: String - } - - struct SeededWebPage { - let documentId: String - let urlString: String - } - - static let shared = LocalFirebaseRESTSupport() - - private let authBaseURL = URL(string: "http://127.0.0.1:9298")! - private let firestoreBaseURL = URL(string: "http://127.0.0.1:8280")! - private let functionsBaseURL = URL(string: "http://127.0.0.1:5201")! - - private init() { } - - func anonymousSignIn() async throws -> AuthSession { - let googleServiceInfo = try loadGoogleServiceInfo() - var request = URLRequest( - url: authBaseURL.appending( - path: "identitytoolkit.googleapis.com/v1/accounts:signUp", - directoryHint: .notDirectory - ).appending(queryItems: [ - URLQueryItem(name: "key", value: googleServiceInfo.apiKey) - ]) - ) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data( - withJSONObject: ["returnSecureToken": true] - ) - - let payload = try await sendJSON(request) - guard - let userId = payload["localId"] as? String, - let idToken = payload["idToken"] as? String - else { - throw RESTError.invalidResponse - } - - return AuthSession( - userId: userId, - idToken: idToken - ) - } - - func seedPushNotification( - userId: String, - notificationId: String = UUID().uuidString - ) async throws -> String { - let fields = [ - "title": stringValue("테스트 알림"), - "body": stringValue("undo 통합 테스트"), - "receivedAt": timestampValue(Date()), - "isRead": booleanValue(false), - "todoId": stringValue("todo-\(notificationId)"), - "todoCategory": stringValue("feature"), - "isDeleted": booleanValue(false) - ] - - try await upsertDocument( - documentPath: "users/\(userId)/notifications/\(notificationId)", - fields: fields - ) - - return notificationId - } - - func seedWebPage( - userId: String, - documentId: String = UUID().uuidString, - urlString: String = "https://example.com/\(UUID().uuidString)" - ) async throws -> SeededWebPage { - let fields = [ - "title": stringValue("Example"), - "url": stringValue(urlString), - "displayURL": stringValue(urlString), - "imageURL": stringValue(""), - "isDeleted": booleanValue(false) - ] - - try await upsertDocument( - documentPath: "users/\(userId)/webPages/\(documentId)", - fields: fields - ) - - return SeededWebPage( - documentId: documentId, - urlString: urlString - ) - } - - func requestPushNotificationDeletion( - notificationId: String, - idToken: String - ) async throws { - _ = try await callFunction( - name: "requestPushNotificationDeletion", - idToken: idToken, - data: ["notificationId": notificationId] - ) - } - - func undoPushNotificationDeletion( - notificationId: String, - idToken: String - ) async throws { - _ = try await callFunction( - name: "undoPushNotificationDeletion", - idToken: idToken, - data: ["notificationId": notificationId] - ) - } - - func requestWebPageDeletion( - urlString: String, - idToken: String - ) async throws { - _ = try await callFunction( - name: "requestWebPageDeletion", - idToken: idToken, - data: ["urlString": urlString] - ) - } - - func undoWebPageDeletion( - urlString: String, - idToken: String - ) async throws { - _ = try await callFunction( - name: "undoWebPageDeletion", - idToken: idToken, - data: ["urlString": urlString] - ) - } - - func fetchPushNotificationIDs(userId: String) async throws -> [String] { - let googleServiceInfo = try loadGoogleServiceInfo() - let url = firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())/documents/users/" + - "\(userId)/notifications", - directoryHint: .notDirectory - ) - let (data, response) = try await URLSession.shared.data(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw RESTError.invalidResponse - } - guard 200 ..< 300 ~= httpResponse.statusCode else { - let body = String(data: data, encoding: .utf8) ?? "" - throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) - } - - let payload = try decodeJSON(data) - let documents = payload["documents"] as? [[String: Any]] ?? [] - - return documents.compactMap { document in - guard - let name = document["name"] as? String, - let fields = document["fields"] as? [String: [String: Any]], - boolValue(for: "isDeleted", in: fields) != true - else { - return nil - } - - return name.split(separator: "/").last.map(String.init) - } - } - - func fetchWebPageURLs(userId: String) async throws -> [String] { - let googleServiceInfo = try loadGoogleServiceInfo() - let url = firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())" + - "/documents/users/\(userId)/webPages", - directoryHint: .notDirectory - ) - let (data, response) = try await URLSession.shared.data(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw RESTError.invalidResponse - } - guard 200 ..< 300 ~= httpResponse.statusCode else { - let body = String(data: data, encoding: .utf8) ?? "" - throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) - } - - let payload = try decodeJSON(data) - let documents = payload["documents"] as? [[String: Any]] ?? [] - - return documents.compactMap { document in - guard - let fields = document["fields"] as? [String: [String: Any]], - boolValue(for: "isDeleted", in: fields) != true - else { - return nil - } - - return fields["url"]?["stringValue"] as? String - } - } - - func waitUntil( - timeout: Duration = .seconds(3), - pollInterval: Duration = .milliseconds(100), - _ condition: @escaping () async throws -> Bool - ) async throws { - let continuousClock = ContinuousClock() - let deadline = continuousClock.now + timeout - - while continuousClock.now < deadline { - if try await condition() { - return - } - try await Task.sleep(for: pollInterval) - } - - throw RESTError.timedOut - } -} - -private extension LocalFirebaseRESTSupport { - struct GoogleServiceInfo { - let apiKey: String - let projectId: String - } - - enum RESTError: Error { - case invalidResponse - case unsuccessfulStatusCode(Int, String) - case missingConfiguration - case timedOut - } - - func loadGoogleServiceInfo() throws -> GoogleServiceInfo { - var fileURL = URL(fileURLWithPath: #filePath) - - while fileURL.lastPathComponent != "DevLog_iOS" { - let nextURL = fileURL.deletingLastPathComponent() - if nextURL == fileURL { - throw RESTError.missingConfiguration - } - fileURL = nextURL - } - - let plistURL = fileURL - .appending(path: "DevLog") - .appending(path: "Resource") - .appending(path: "GoogleService-Info.plist") - let data = try Data(contentsOf: plistURL) - guard - let payload = try PropertyListSerialization.propertyList( - from: data, - options: [], - format: nil - ) as? [String: Any], - let apiKey = payload["API_KEY"] as? String, - let projectId = payload["PROJECT_ID"] as? String - else { - throw RESTError.missingConfiguration - } - - return GoogleServiceInfo( - apiKey: apiKey, - projectId: projectId - ) - } - - func callFunction( - name: String, - idToken: String, - data: [String: Any] - ) async throws -> [String: Any] { - let googleServiceInfo = try loadGoogleServiceInfo() - var request = URLRequest( - url: functionsBaseURL.appending( - path: "\(googleServiceInfo.projectId)/asia-northeast3/\(name)", - directoryHint: .notDirectory - ) - ) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") - request.httpBody = try JSONSerialization.data(withJSONObject: ["data": data]) - return try await sendJSON(request) - } - - func upsertDocument( - documentPath: String, - fields: [String: [String: Any]] - ) async throws { - let googleServiceInfo = try loadGoogleServiceInfo() - let encodedPath = encode(documentPath) - var request = URLRequest( - url: firestoreBaseURL.appending( - path: "v1/projects/\(googleServiceInfo.projectId)/databases/\(databaseID())" + - "/documents/\(encodedPath)", - directoryHint: .notDirectory - ) - ) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: ["fields": fields]) - _ = try await sendJSON(request) - } - - func sendJSON(_ request: URLRequest) async throws -> [String: Any] { - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw RESTError.invalidResponse - } - guard 200 ..< 300 ~= httpResponse.statusCode else { - let body = String(data: data, encoding: .utf8) ?? "" - throw RESTError.unsuccessfulStatusCode(httpResponse.statusCode, body) - } - return try decodeJSON(data) - } - - func decodeJSON(_ data: Data) throws -> [String: Any] { - guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw RESTError.invalidResponse - } - return payload - } - - func encode(_ path: String) -> String { - path.split(separator: "/").map { - String($0).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String($0) - }.joined(separator: "/") - } - - func databaseID() -> String { - let environmentValue = ProcessInfo.processInfo.environment["FIRESTORE_DATABASE_ID"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let environmentValue, !environmentValue.isEmpty { - return environmentValue - } - - let bundleValue = Bundle.main.object(forInfoDictionaryKey: "FIRESTORE_DATABASE_ID") as? String - let databaseID = bundleValue?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let databaseID, !databaseID.isEmpty, !databaseID.hasPrefix("$(") else { - return "staging" - } - - return databaseID - } - - func stringValue(_ value: String) -> [String: Any] { - ["stringValue": value] - } - - func booleanValue(_ value: Bool) -> [String: Any] { - ["booleanValue": value] - } - - func timestampValue(_ value: Date) -> [String: Any] { - ["timestampValue": value.formatted(.iso8601)] - } - - func boolValue( - for field: String, - in fields: [String: [String: Any]]? - ) -> Bool? { - fields?[field]?["booleanValue"] as? Bool - } - -} diff --git a/Application/App/Tests/WebPage/Integration/DeleteWebPageIntegrationTests.swift b/Application/App/Tests/WebPage/Integration/DeleteWebPageIntegrationTests.swift deleted file mode 100644 index e9913b68..00000000 --- a/Application/App/Tests/WebPage/Integration/DeleteWebPageIntegrationTests.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DeleteWebPageIntegrationTests.swift -// AppTests -// -// Created by opfic on 4/6/26. -// - -import Testing -import Foundation - -@Suite(.serialized) -struct DeleteWebPageIntegrationTests { - @Test("웹페이지 삭제를 되돌리면 목록에 보인다") - func 웹페이지_삭제를_되돌리면_목록에_보인다() async throws { - let authSession = try await LocalFirebaseRESTSupport.shared.anonymousSignIn() - let seededWebPage = try await LocalFirebaseRESTSupport.shared.seedWebPage( - userId: authSession.userId - ) - - try await LocalFirebaseRESTSupport.shared.requestWebPageDeletion( - urlString: seededWebPage.urlString, - idToken: authSession.idToken - ) - - try await LocalFirebaseRESTSupport.shared.waitUntil { - let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( - userId: authSession.userId - ) - return !visibleWebPageURLs.contains(seededWebPage.urlString) - } - - try await LocalFirebaseRESTSupport.shared.undoWebPageDeletion( - urlString: seededWebPage.urlString, - idToken: authSession.idToken - ) - - try await LocalFirebaseRESTSupport.shared.waitUntil { - let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( - userId: authSession.userId - ) - return visibleWebPageURLs.contains(seededWebPage.urlString) - } - - try await Task.sleep(for: .seconds(6)) - - let visibleWebPageURLs = try await LocalFirebaseRESTSupport.shared.fetchWebPageURLs( - userId: authSession.userId - ) - #expect(visibleWebPageURLs.contains(seededWebPage.urlString)) - } -} From 031fd8e25110ee50e8b846fa8cbb0ff6f676727c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:35:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20UserDefaults=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Assembler/AppLayerAssembler.swift | 3 +- .../App/Handler/FCMTokenSyncHandler.swift | 30 +----- .../FCMTokenSyncHandlerTests.swift | 91 +------------------ 3 files changed, 8 insertions(+), 116 deletions(-) diff --git a/Application/App/Sources/App/Assembler/AppLayerAssembler.swift b/Application/App/Sources/App/Assembler/AppLayerAssembler.swift index 9c787b87..66550973 100644 --- a/Application/App/Sources/App/Assembler/AppLayerAssembler.swift +++ b/Application/App/Sources/App/Assembler/AppLayerAssembler.swift @@ -14,8 +14,7 @@ final class AppLayerAssembler: Assembler { FCMTokenSyncHandler( authService: container.resolve(AuthService.self), messagingService: container.resolve(PushMessagingService.self), - userService: container.resolve(UserService.self), - store: container.resolve(UserDefaultsStore.self) + userService: container.resolve(UserService.self) ) } container.register(UserTimeZoneSyncHandler.self) { diff --git a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift index 8b95f946..a5fe03b4 100644 --- a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift +++ b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift @@ -7,20 +7,13 @@ import Combine import Core -import CryptoKit import Data import Foundation -import Infra final class FCMTokenSyncHandler { - private enum Key { - static let fcmTokenHash = "FCMTokenSyncHandler.fcmTokenHash" - } - private let authService: AuthService private let messagingService: PushMessagingService private let userService: UserService - private let store: UserDefaultsStore private let notificationCenter: NotificationCenter private let logger = Logger(category: "FCMTokenSyncHandler") private var cancellables = Set() @@ -29,13 +22,11 @@ final class FCMTokenSyncHandler { authService: AuthService, messagingService: PushMessagingService, userService: UserService, - store: UserDefaultsStore, notificationCenter: NotificationCenter = .default ) { self.authService = authService self.messagingService = messagingService self.userService = userService - self.store = store self.notificationCenter = notificationCenter authService.observeSignedIn() @@ -69,10 +60,7 @@ final class FCMTokenSyncHandler { private extension FCMTokenSyncHandler { func handleSessionUpdate(isSignedIn: Bool) { - guard isSignedIn else { - store.setString(nil, forKey: Key.fcmTokenHash) - return - } + guard isSignedIn else { return } requestFCMTokenSync() } @@ -113,25 +101,11 @@ private extension FCMTokenSyncHandler { } func syncFCMTokenIfNeeded(_ fcmToken: String) async throws { - guard let uid = authService.uid else { - store.setString(nil, forKey: Key.fcmTokenHash) + guard authService.uid != nil else { logger.info("Skipping FCM token update because no authenticated user exists") return } - let tokenHash = makeTokenHash(uid: uid, fcmToken: fcmToken) - guard store.string(forKey: Key.fcmTokenHash) != tokenHash else { - logger.info("Skipping FCM token update because token hash is unchanged") - return - } - try await userService.updateFCMToken(fcmToken) - store.setString(tokenHash, forKey: Key.fcmTokenHash) - } - - func makeTokenHash(uid: String, fcmToken: String) -> String { - let value = "\(uid)|\(FirebaseConfiguration.databaseID)|\(fcmToken)" - let digest = SHA256.hash(data: Data(value.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() } } diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift index 6f63363c..cd5149bb 100644 --- a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift +++ b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -22,12 +22,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -46,12 +44,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) let userService = UserServiceSpy() let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -68,12 +64,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) let userService = UserServiceSpy() let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -95,12 +89,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) let deviceToken = Data([0x01, 0x02, 0x03]) @@ -118,18 +110,16 @@ struct FCMTokenSyncHandlerTests { _ = handler } - @Test("같은 사용자와 같은 FCM token은 한 번만 저장한다") - func 같은_사용자와_같은_FCM_token은_한_번만_저장한다() async throws { + @Test("같은 사용자와 같은 FCM token도 매번 저장한다") + func 같은_사용자와_같은_FCM_token도_매번_저장한다() async throws { let notificationCenter = NotificationCenter() let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -139,9 +129,10 @@ struct FCMTokenSyncHandlerTests { } notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await Task.sleep(for: .milliseconds(100)) + try await waitUntil { + await userService.updatedFCMTokens == ["current-token", "current-token"] + } - #expect(await userService.updatedFCMTokens == ["current-token"]) _ = handler } @@ -151,12 +142,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() let authService = AuthServiceSpy(uid: "first-user") - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -179,12 +168,10 @@ struct FCMTokenSyncHandlerTests { let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") let userService = UserServiceSpy() let authService = AuthServiceSpy(uid: nil) - let store = UserDefaultsStoreSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, userService: userService, - store: store, notificationCenter: notificationCenter ) @@ -196,34 +183,6 @@ struct FCMTokenSyncHandlerTests { _ = handler } - @Test("로그아웃 세션 전이 시 저장된 FCM token hash를 제거한다") - func 로그아웃_세션_전이_시_저장된_FCM_token_hash를_제거한다() async throws { - let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() - let store = UserDefaultsStoreSpy() - let handler = FCMTokenSyncHandler( - authService: authService, - messagingService: messagingService, - userService: userService, - store: store, - notificationCenter: notificationCenter - ) - - notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { - store.hasStoredString - } - - authService.updateSession(uid: nil) - - try await waitUntil { - !store.hasStoredString - } - _ = handler - } - } private actor UserServiceSpy: UserService { @@ -287,46 +246,6 @@ private final class PushMessagingServiceSpy: PushMessagingService { } } -private final class UserDefaultsStoreSpy: UserDefaultsStore { - private var strings = [String: String]() - - var hasStoredString: Bool { - !strings.isEmpty - } - - func value(forKey key: String) -> T? { - nil - } - - func setValue(_ value: T?, forKey key: String) { } - - func removeValues(withPrefix prefix: String) { - strings.keys - .filter { $0.hasPrefix(prefix) } - .forEach { strings.removeValue(forKey: $0) } - } - - func string(forKey key: String) -> String? { - strings[key] - } - - func setString(_ value: String?, forKey key: String) { - strings[key] = value - } - - func stringArray(forKey key: String) -> [String] { - [] - } - - func setStringArray(_ value: [String], forKey key: String) { } - - func bool(forKey key: String) -> Bool { - false - } - - func setBool(_ value: Bool, forKey key: String) { } -} - private final class NotificationObserver { private(set) var didReceiveNotification = false private var token: NSObjectProtocol? From 4176861e13ac4d1c19d1a65101e3998b4975e554 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:33:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20foreground=20=EB=B3=B5=EA=B7=80=20?= =?UTF-8?q?=EC=8B=9C=20APNs=20=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/App/Delegate/AppDelegate.swift | 4 +++ .../App/Handler/FCMTokenSyncHandler.swift | 17 ++++++++++-- .../App/Notification/NotificationName+.swift | 1 + .../FCMTokenSyncHandlerTests.swift | 27 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Application/App/Sources/App/Delegate/AppDelegate.swift b/Application/App/Sources/App/Delegate/AppDelegate.swift index f0400a36..d8921649 100644 --- a/Application/App/Sources/App/Delegate/AppDelegate.swift +++ b/Application/App/Sources/App/Delegate/AppDelegate.swift @@ -73,6 +73,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + func applicationWillEnterForeground(_ application: UIApplication) { + NotificationCenter.default.post(name: .didRequestAPNsRegistration, object: nil) + } + // APNs 등록 성공 func application( _ application: UIApplication, diff --git a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift index a5fe03b4..2696e326 100644 --- a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift +++ b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift @@ -49,6 +49,14 @@ final class FCMTokenSyncHandler { } .store(in: &cancellables) + notificationCenter.publisher(for: .didRequestAPNsRegistration) + .sink { [weak self] _ in + Task { + await self?.requestAPNsRegistrationIfAuthorized() + } + } + .store(in: &cancellables) + notificationCenter.publisher(for: .didReceiveAPNSToken) .compactMap { $0.userInfo?["deviceToken"] as? Data } .sink { [weak self] deviceToken in @@ -68,12 +76,17 @@ private extension FCMTokenSyncHandler { func requestFCMTokenSync() { Task { [weak self] in guard let self else { return } - guard await messagingService.isNotificationAuthorized() else { return } - notificationCenter.post(name: .didRequestRemoteNotificationRegistration, object: nil) + guard await requestAPNsRegistrationIfAuthorized() else { return } await syncCurrentFCMToken() } } + func requestAPNsRegistrationIfAuthorized() async -> Bool { + guard await messagingService.isNotificationAuthorized() else { return false } + notificationCenter.post(name: .didRequestRemoteNotificationRegistration, object: nil) + return true + } + func handleAPNSToken(_ deviceToken: Data) { messagingService.setAPNSToken(deviceToken) Task { [weak self] in diff --git a/Application/App/Sources/App/Notification/NotificationName+.swift b/Application/App/Sources/App/Notification/NotificationName+.swift index 84aaef68..1903d66e 100644 --- a/Application/App/Sources/App/Notification/NotificationName+.swift +++ b/Application/App/Sources/App/Notification/NotificationName+.swift @@ -10,6 +10,7 @@ import Foundation extension Notification.Name { static let didRefreshFCMToken = Notification.Name("didRefreshFCMToken") static let didReceiveAPNSToken = Notification.Name("didReceiveAPNSToken") + static let didRequestAPNsRegistration = Notification.Name("didRequestAPNsRegistration") static let didRequestFCMTokenSync = Notification.Name("didRequestFCMTokenSync") static let didRequestRemoteNotificationRegistration = Notification.Name("didRequestRemoteNotificationRegistration") static let didRequestUserTimeZoneSync = Notification.Name("didRequestUserTimeZoneSync") diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift index cd5149bb..2ae9a84a 100644 --- a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift +++ b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -38,6 +38,33 @@ struct FCMTokenSyncHandlerTests { _ = handler } + @Test("foreground 복귀 시 알림 권한이 있으면 APNs 등록을 요청하고 FCM token은 직접 저장하지 않는다") + func foreground_복귀_시_알림_권한이_있으면_APNs_등록을_요청하고_FCM_token은_직접_저장하지_않는다() async throws { + let notificationCenter = NotificationCenter() + let registrationObserver = NotificationObserver( + notificationCenter: notificationCenter, + name: .didRequestRemoteNotificationRegistration + ) + let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = UserServiceSpy() + let authService = AuthServiceSpy() + let handler = FCMTokenSyncHandler( + authService: authService, + messagingService: messagingService, + userService: userService, + notificationCenter: notificationCenter + ) + + notificationCenter.post(name: .didRequestAPNsRegistration, object: nil) + + try await waitUntil { + registrationObserver.didReceiveNotification + } + try await Task.sleep(for: .milliseconds(100)) + #expect(await userService.updatedFCMTokens.isEmpty) + _ = handler + } + @Test("현재 FCM token 동기화 요청 시 token이 없으면 저장하지 않는다") func 현재_FCM_token_동기화_요청_시_token이_없으면_저장하지_않는다() async throws { let notificationCenter = NotificationCenter() From 53def72651c7a9ef29094fcf330a16a848ff881f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:24:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B6=8C=ED=95=9C=20=EC=95=88=EB=82=B4=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/App/Sources/Resource/Localizable.xcstrings | 4 ++-- .../Sources/Settings/PushNotificationSettingsView.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index 6a0c1aee..a687bcb7 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -1176,13 +1176,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Separate from your Settings app notification preference." + "value" : "Separate from the push notification permission in Settings.\nNotification list items are still created even when device notification permission is off." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "설정에서의 푸시 알람 설정과는 별개예요." + "value" : "설정의 푸시 알람 권한과는 별개예요.\n기기 알림 권한을 꺼도 알림 리스트는 생성돼요." } } } diff --git a/Application/Presentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/Presentation/Sources/Settings/PushNotificationSettingsView.swift index f6879e2f..853b7985 100644 --- a/Application/Presentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/Presentation/Sources/Settings/PushNotificationSettingsView.swift @@ -28,6 +28,7 @@ struct PushNotificationSettingsView: View { } }, footer: { Text(String(localized: "push_settings_footer")) + .multilineTextAlignment(.leading) }) Section { ForEach([9, 15, 18, 21], id: \.self) { hour in