From d57d3f8a1cd33e09d2ba1aeca0a7cbae356d9416 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 11:35:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=AA=A9=EB=A1=9D=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=95=B1=20=EC=96=B8=EC=96=B4=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Resource/Localizable.xcstrings | 51 ++++++++++++ .../DTO/PushNotificationResponse.swift | 49 +++++++++-- .../Mapper/PushNotificationMapping.swift | 14 +++- .../PushNotificationRepositoryImpl.swift | 14 +++- .../Mapper/PushNotificationMappingTests.swift | 47 +++++++++++ .../Sources/Entity/PushNotification.swift | 49 +++++++++-- .../Mapper/PushNotificationMapper.swift | 67 +++++++++++++++ .../Service/PushNotificationServiceImpl.swift | 35 +------- .../Mapper/PushNotificationMapperTests.swift | 82 +++++++++++++++++++ .../PushNotificationItem.swift | 31 ++++++- .../PushNotificationItemTests.swift | 65 +++++++++++++++ .../PushNotificationListFixtures.swift | 24 +++++- 12 files changed, 472 insertions(+), 56 deletions(-) create mode 100644 Application/Data/Tests/Mapper/PushNotificationMappingTests.swift create mode 100644 Application/Infra/Sources/Mapper/PushNotificationMapper.swift create mode 100644 Application/Infra/Tests/Mapper/PushNotificationMapperTests.swift create mode 100644 Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index 6b221cbd..5d85cd87 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -1083,6 +1083,57 @@ } } }, + "push_notification_todo_due_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo Reminder" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 알림" + } + } + } + }, + "push_notification_todo_due_tomorrow_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ is due tomorrow." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%1$@'의 마감일이 내일입니다." + } + } + } + }, + "push_notification_todo_due_tomorrow_without_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An untitled Todo is due tomorrow." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 없는 Todo의 마감일이 내일입니다." + } + } + } + }, "push_notifications_empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Data/Sources/DTO/PushNotificationResponse.swift b/Application/Data/Sources/DTO/PushNotificationResponse.swift index e6960e20..f574715b 100644 --- a/Application/Data/Sources/DTO/PushNotificationResponse.swift +++ b/Application/Data/Sources/DTO/PushNotificationResponse.swift @@ -9,9 +9,29 @@ import Foundation import Domain public struct PushNotificationResponse { + private struct LegacyValue { + let title: String + let body: String + } + + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public struct Legacy { + public let title: String + public let body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } + } + public let id: String - public let title: String - public let body: String + public let todoTitle: String? + private let legacyValue: LegacyValue? + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public var legacy: Legacy? { + legacyValue.map { Legacy(title: $0.title, body: $0.body) } + } public let receivedAt: Date public let isRead: Bool public let todoId: String @@ -19,16 +39,33 @@ public struct PushNotificationResponse { public init( id: String, - title: String, - body: String, + todoTitle: String?, + receivedAt: Date, + isRead: Bool, + todoId: String, + todoCategory: TodoCategoryResponse + ) { + self.id = id + self.todoTitle = todoTitle + self.legacyValue = nil + self.receivedAt = receivedAt + self.isRead = isRead + self.todoId = todoId + self.todoCategory = todoCategory + } + + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public init( + id: String, + legacy: Legacy, receivedAt: Date, isRead: Bool, todoId: String, todoCategory: TodoCategoryResponse ) { self.id = id - self.title = title - self.body = body + self.todoTitle = nil + self.legacyValue = LegacyValue(title: legacy.title, body: legacy.body) self.receivedAt = receivedAt self.isRead = isRead self.todoId = todoId diff --git a/Application/Data/Sources/Mapper/PushNotificationMapping.swift b/Application/Data/Sources/Mapper/PushNotificationMapping.swift index 8b1848e1..c2a7ecf7 100644 --- a/Application/Data/Sources/Mapper/PushNotificationMapping.swift +++ b/Application/Data/Sources/Mapper/PushNotificationMapping.swift @@ -20,10 +20,20 @@ public extension PushNotificationResponse { ) } + if let legacy { + return PushNotification( + id: id, + legacy: .init(title: legacy.title, body: legacy.body), + receivedAt: self.receivedAt, + isRead: self.isRead, + todoId: self.todoId, + todoCategory: todoCategory + ) + } + return PushNotification( id: id, - title: self.title, - body: self.body, + todoTitle: todoTitle, receivedAt: self.receivedAt, isRead: self.isRead, todoId: self.todoId, diff --git a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift index 7f9734de..dc39fe41 100644 --- a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift @@ -211,10 +211,20 @@ private extension PushNotificationRepositoryImpl { throw DataLayerError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") } + if let legacy = response.legacy { + return PushNotificationResponse( + id: response.id, + legacy: legacy, + receivedAt: response.receivedAt, + isRead: response.isRead, + todoId: response.todoId, + todoCategory: .decoded(todoCategory) + ) + } + return PushNotificationResponse( id: response.id, - title: response.title, - body: response.body, + todoTitle: response.todoTitle, receivedAt: response.receivedAt, isRead: response.isRead, todoId: response.todoId, diff --git a/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift b/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift new file mode 100644 index 00000000..fdb0a2a4 --- /dev/null +++ b/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift @@ -0,0 +1,47 @@ +// +// PushNotificationMappingTests.swift +// DataTests +// +// Created by opfic on 7/20/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct PushNotificationMappingTests { + @Test("Todo 제목을 알림 도메인 모델에 전달한다") + func Todo_제목을_알림_도메인_모델에_전달한다() throws { + let response = PushNotificationResponse( + id: "notification-1", + todoTitle: "테스트 작성", + receivedAt: Date(timeIntervalSince1970: 1), + isRead: false, + todoId: "todo-1", + todoCategory: .decoded(.system(.feature)) + ) + + let notification = try response.toDomain() + + #expect(notification.todoTitle == "테스트 작성") + } + + @Test("기존 알림 문구를 Legacy 래퍼로 전달한다") + func 기존_알림_문구를_Legacy_래퍼로_전달한다() throws { + let response = PushNotificationResponse( + id: "notification-1", + legacy: .init(title: "기존 제목", body: "기존 본문"), + receivedAt: Date(timeIntervalSince1970: 1), + isRead: false, + todoId: "todo-1", + todoCategory: .decoded(.system(.feature)) + ) + + let notification = try response.toDomain() + let legacy = try #require(notification.legacy) + + #expect(legacy.title == "기존 제목") + #expect(legacy.body == "기존 본문") + } +} diff --git a/Application/Domain/Sources/Entity/PushNotification.swift b/Application/Domain/Sources/Entity/PushNotification.swift index 5a9bd48c..958579a3 100644 --- a/Application/Domain/Sources/Entity/PushNotification.swift +++ b/Application/Domain/Sources/Entity/PushNotification.swift @@ -8,9 +8,29 @@ import Foundation public struct PushNotification: Hashable { + private struct LegacyValue: Hashable { + let title: String + let body: String + } + + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public struct Legacy: Hashable { + public let title: String + public let body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } + } + public let id: String - public let title: String - public let body: String + public let todoTitle: String? + private let legacyValue: LegacyValue? + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public var legacy: Legacy? { + legacyValue.map { Legacy(title: $0.title, body: $0.body) } + } public let receivedAt: Date public var isRead: Bool public let todoId: String @@ -18,16 +38,33 @@ public struct PushNotification: Hashable { public init( id: String, - title: String, - body: String, + todoTitle: String?, + receivedAt: Date, + isRead: Bool, + todoId: String, + todoCategory: TodoCategory + ) { + self.id = id + self.todoTitle = todoTitle + self.legacyValue = nil + self.receivedAt = receivedAt + self.isRead = isRead + self.todoId = todoId + self.todoCategory = todoCategory + } + + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + public init( + id: String, + legacy: Legacy, receivedAt: Date, isRead: Bool, todoId: String, todoCategory: TodoCategory ) { self.id = id - self.title = title - self.body = body + self.todoTitle = nil + self.legacyValue = LegacyValue(title: legacy.title, body: legacy.body) self.receivedAt = receivedAt self.isRead = isRead self.todoId = todoId diff --git a/Application/Infra/Sources/Mapper/PushNotificationMapper.swift b/Application/Infra/Sources/Mapper/PushNotificationMapper.swift new file mode 100644 index 00000000..5e1f7f06 --- /dev/null +++ b/Application/Infra/Sources/Mapper/PushNotificationMapper.swift @@ -0,0 +1,67 @@ +// +// PushNotificationMapper.swift +// Infra +// +// Created by opfic on 7/20/26. +// + +import Data +import FirebaseFirestore + +struct PushNotificationMapper { + func map(documentID: String, data: [String: Any]) -> PushNotificationResponse? { + if (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { + return nil + } + guard + let receivedAt = data[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp, + let isRead = data[PushNotificationFieldKey.isRead.rawValue] as? Bool, + let todoId = data[PushNotificationFieldKey.todoId.rawValue] as? String, + let todoCategory = data[PushNotificationFieldKey.todoCategory.rawValue] as? String else { + return nil + } + + if let todoTitle = data[PushNotificationFieldKey.todoTitle.rawValue] as? String { + return PushNotificationResponse( + id: documentID, + todoTitle: todoTitle, + receivedAt: receivedAt.dateValue(), + isRead: isRead, + todoId: todoId, + todoCategory: .raw(todoCategory) + ) + } + + if let title = data[PushNotificationFieldKey.title.rawValue] as? String, + let body = data[PushNotificationFieldKey.body.rawValue] as? String { + return PushNotificationResponse( + id: documentID, + legacy: .init(title: title, body: body), + receivedAt: receivedAt.dateValue(), + isRead: isRead, + todoId: todoId, + todoCategory: .raw(todoCategory) + ) + } + + return PushNotificationResponse( + id: documentID, + todoTitle: nil, + receivedAt: receivedAt.dateValue(), + isRead: isRead, + todoId: todoId, + todoCategory: .raw(todoCategory) + ) + } +} + +enum PushNotificationFieldKey: String { + case title + case body + case todoTitle + case receivedAt + case isRead + case todoId + case todoCategory + case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 +} diff --git a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift index aa513e20..fc28d6ee 100644 --- a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift @@ -30,6 +30,7 @@ final class PushNotificationServiceImpl: PushNotificationService { private let store = FirebaseConfiguration.firestore private let logger = Logger(category: "PushNotificationServiceImpl") + private let mapper = PushNotificationMapper() /// 푸시 알림 On/Off 설정 func fetchPushNotificationEnabled() async throws -> Bool { @@ -343,38 +344,6 @@ private extension PushNotificationServiceImpl { } func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? { - let data = snapshot.data() - if (data[PushNotificationFieldKey.isDeleted.rawValue] as? Bool) == true { - return nil - } - guard - let title = data[PushNotificationFieldKey.title.rawValue] as? String, - let body = data[PushNotificationFieldKey.body.rawValue] as? String, - let receivedAt = data[PushNotificationFieldKey.receivedAt.rawValue] as? Timestamp, - let isRead = data[PushNotificationFieldKey.isRead.rawValue] as? Bool, - let todoId = data[PushNotificationFieldKey.todoId.rawValue] as? String, - let todoCategory = data[PushNotificationFieldKey.todoCategory.rawValue] as? String else { - return nil - } - - return PushNotificationResponse( - id: snapshot.documentID, - title: title, - body: body, - receivedAt: receivedAt.dateValue(), - isRead: isRead, - todoId: todoId, - todoCategory: .raw(todoCategory) - ) - } - - enum PushNotificationFieldKey: String { - case title - case body - case receivedAt - case isRead - case todoId - case todoCategory - case isDeleted // 삭제 요청으로 서버에서 soft deletion이 된 상태 + mapper.map(documentID: snapshot.documentID, data: snapshot.data()) } } diff --git a/Application/Infra/Tests/Mapper/PushNotificationMapperTests.swift b/Application/Infra/Tests/Mapper/PushNotificationMapperTests.swift new file mode 100644 index 00000000..12eab756 --- /dev/null +++ b/Application/Infra/Tests/Mapper/PushNotificationMapperTests.swift @@ -0,0 +1,82 @@ +// +// PushNotificationMapperTests.swift +// InfraTests +// +// Created by opfic on 7/20/26. +// + +import FirebaseFirestore +import Foundation +import Testing +@testable import Infra + +struct PushNotificationMapperTests { + @Test("Todo 제목 문서를 신규 응답으로 변환한다") + func Todo_제목_문서를_신규_응답으로_변환한다() throws { + var data = makeData() + data[PushNotificationFieldKey.todoTitle.rawValue] = "테스트 작성" + + let response = try #require( + PushNotificationMapper().map(documentID: "notification-1", data: data) + ) + + #expect(response.todoTitle == "테스트 작성") + } + + @Test("신규 필드와 기존 필드가 함께 있으면 신규 응답을 우선한다") + func 신규_필드와_기존_필드가_함께_있으면_신규_응답을_우선한다() throws { + var data = makeData() + data[PushNotificationFieldKey.todoTitle.rawValue] = "테스트 작성" + data[PushNotificationFieldKey.title.rawValue] = "기존 제목" + data[PushNotificationFieldKey.body.rawValue] = "기존 본문" + + let response = try #require( + PushNotificationMapper().map(documentID: "notification-1", data: data) + ) + + #expect(response.todoTitle == "테스트 작성") + } + + @Test("기존 제목과 본문 문서를 Legacy 응답으로 변환한다") + func 기존_제목과_본문_문서를_Legacy_응답으로_변환한다() throws { + var data = makeData() + data[PushNotificationFieldKey.title.rawValue] = "기존 제목" + data[PushNotificationFieldKey.body.rawValue] = "기존 본문" + + let response = try #require( + PushNotificationMapper().map(documentID: "notification-1", data: data) + ) + let legacy = try #require(response.legacy) + + #expect(legacy.title == "기존 제목") + #expect(legacy.body == "기존 본문") + } + + @Test("Todo 제목이 null이면 기존 제목과 본문을 우선한다") + func Todo_제목이_null이면_기존_제목과_본문을_우선한다() throws { + var data = makeData() + data[PushNotificationFieldKey.todoTitle.rawValue] = NSNull() + data[PushNotificationFieldKey.title.rawValue] = "기존 제목" + data[PushNotificationFieldKey.body.rawValue] = "기존 본문" + + let response = try #require( + PushNotificationMapper().map(documentID: "notification-1", data: data) + ) + let legacy = try #require(response.legacy) + + #expect(legacy.title == "기존 제목") + #expect(legacy.body == "기존 본문") + } + + private func makeData() -> [String: Any] { + [ + PushNotificationFieldKey.receivedAt.rawValue: Timestamp( + date: Date(timeIntervalSince1970: 1) + ), + PushNotificationFieldKey.isRead.rawValue: false, + PushNotificationFieldKey.todoId.rawValue: "todo-1", + PushNotificationFieldKey.todoCategory.rawValue: "feature", + PushNotificationFieldKey.isDeleted.rawValue: false + ] + } +} diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift index d5001e45..ca4623b3 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift @@ -11,20 +11,43 @@ import Domain public struct PushNotificationItem: Identifiable, Hashable { public let id: String public var isHidden = false - public let title: String - public let body: String public let receivedAt: Date public var isRead: Bool public let todoId: String public let todoCategory: TodoCategory + private let todoTitle: String? + @available(*, deprecated, message: "todoTitle 기반 알림을 사용한다.") + private let legacy: PushNotification.Legacy? + + public var title: String { + if let legacy { + return legacy.title + } + return String(localized: "push_notification_todo_due_title") + } + + public var body: String { + if let legacy { + return legacy.body + } + guard let todoTitle, + !todoTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return String(localized: "push_notification_todo_due_tomorrow_without_title") + } + return String.localizedStringWithFormat( + String(localized: "push_notification_todo_due_tomorrow_format"), + todoTitle + ) + } + public init(from notification: PushNotification) { self.id = notification.id - self.title = notification.title - self.body = notification.body self.receivedAt = notification.receivedAt self.isRead = notification.isRead self.todoId = notification.todoId self.todoCategory = notification.todoCategory + self.todoTitle = notification.todoTitle + self.legacy = notification.legacy } } diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift new file mode 100644 index 00000000..70680136 --- /dev/null +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift @@ -0,0 +1,65 @@ +// +// PushNotificationItemTests.swift +// NotificationTabTests +// +// Created by opfic on 7/20/26. +// + +import Foundation +import Testing +@testable import NotificationTab + +struct PushNotificationItemTests { + @Test("Todo 제목이 있는 알림의 목록 문구를 현지화한다") + func Todo_제목이_있는_알림의_목록_문구를_현지화한다() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + todoTitle: "테스트 작성" + ) + let item = PushNotificationItem(from: notification) + + #expect(item.title == String(localized: "push_notification_todo_due_title")) + #expect( + item.body == String.localizedStringWithFormat( + String(localized: "push_notification_todo_due_tomorrow_format"), + "테스트 작성" + ) + ) + } + + @Test("제목 없는 Todo 알림의 목록 문구를 현지화한다") + func 제목_없는_Todo_알림의_목록_문구를_현지화한다() { + let notification = makePushNotification(id: "notification-1", number: 1) + let item = PushNotificationItem(from: notification) + + #expect(item.title == String(localized: "push_notification_todo_due_title")) + #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) + } + + @Test("공백으로만 구성된 Todo 제목을 제목 없는 알림으로 표시한다") + func 공백으로만_구성된_Todo_제목을_제목_없는_알림으로_표시한다() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + todoTitle: " \n\t " + ) + let item = PushNotificationItem(from: notification) + + #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) + } + + @Test("기존 알림 문서의 저장 문구를 유지한다") + func 기존_알림_문서의_저장_문구를_유지한다() { + let notification = makeLegacyPushNotification( + id: "notification-1", + number: 1, + title: "기존 제목", + body: "기존 본문" + ) + let item = PushNotificationItem(from: notification) + + #expect(item.title == "기존 제목") + #expect(item.body == "기존 본문") + } +} diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift index 7e1008e0..fd62bcc5 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift @@ -11,12 +11,30 @@ import Foundation func makePushNotification( id: String, number: Int, - isRead: Bool = false + isRead: Bool = false, + todoTitle: String? = nil ) -> PushNotification { PushNotification( id: id, - title: "title-\(number)", - body: "body-\(number)", + todoTitle: todoTitle, + receivedAt: Date(timeIntervalSince1970: Double(number)), + isRead: isRead, + todoId: "todo-\(number)", + todoCategory: .system(.feature) + ) +} + +@available(*, deprecated, message: "makePushNotification을 사용한다.") +func makeLegacyPushNotification( + id: String, + number: Int, + isRead: Bool = false, + title: String, + body: String +) -> PushNotification { + PushNotification( + id: id, + legacy: .init(title: title, body: body), receivedAt: Date(timeIntervalSince1970: Double(number)), isRead: isRead, todoId: "todo-\(number)", From 609960da7437363d06c7912b587d3670881b4221 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 11:35:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20FCM=20token=20=EC=95=B1=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Handler/FCMTokenSyncHandler.swift | 17 +- .../FCMTokenSyncHandlerTests.swift | 213 ++++-------------- .../FCMTokenSyncTestDoubles.swift | 129 +++++++++++ .../UserTimeZoneSyncHandlerTests.swift | 2 +- .../Data/Sources/DTO/AuthDataResponse.swift | 5 +- .../Data/Sources/DTO/FCMTokenUpdate.swift | 33 +++ .../Data/Sources/Protocol/UserService.swift | 2 +- .../Data/Tests/DTO/FCMTokenUpdateTests.swift | 29 +++ .../AuthenticationRepositoryImplTests.swift | 2 +- .../Mapper/FCMTokenUpdateMapping.swift | 22 ++ .../Sources/Service/UserServiceImpl.swift | 21 +- .../Mapper/FCMTokenUpdateMappingTests.swift | 27 +++ 12 files changed, 312 insertions(+), 190 deletions(-) create mode 100644 Application/App/Tests/PushNotification/FCMTokenSyncTestDoubles.swift create mode 100644 Application/Data/Sources/DTO/FCMTokenUpdate.swift create mode 100644 Application/Data/Tests/DTO/FCMTokenUpdateTests.swift create mode 100644 Application/Infra/Sources/Mapper/FCMTokenUpdateMapping.swift create mode 100644 Application/Infra/Tests/Mapper/FCMTokenUpdateMappingTests.swift diff --git a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift index b16d2c28..01c64256 100644 --- a/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift +++ b/Application/App/Sources/App/Handler/FCMTokenSyncHandler.swift @@ -14,6 +14,7 @@ final class FCMTokenSyncHandler { private struct SyncKey: Equatable { let uid: String let fcmToken: String + let code: PushLanguageCode } private let authService: AuthService @@ -128,13 +129,25 @@ private extension FCMTokenSyncHandler { return } - let key = SyncKey(uid: uid, fcmToken: fcmToken) + let pushLanguageCode = PushLanguageCode( + identifier: Bundle.main.preferredLocalizations.first + ) + let key = SyncKey( + uid: uid, + fcmToken: fcmToken, + code: pushLanguageCode + ) guard lastSyncedKey != key else { logger.info("Skipping FCM token update because the token was already synced") return } - try await userService.updateFCMToken(fcmToken) + try await userService.updateFCMToken( + FCMTokenUpdate( + fcmToken: fcmToken, + code: pushLanguageCode + ) + ) lastSyncedKey = key } } diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift index b5402c77..f76956bb 100644 --- a/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift +++ b/Application/App/Tests/PushNotification/FCMTokenSyncHandlerTests.swift @@ -6,7 +6,6 @@ // import Foundation -import Combine import Testing import Data @testable import App @@ -15,13 +14,13 @@ struct FCMTokenSyncHandlerTests { @Test("현재 FCM token 동기화 요청 시 token이 있으면 저장한다") func 현재_FCM_token_동기화_요청_시_token이_있으면_저장한다() async throws { let notificationCenter = NotificationCenter() - let registrationObserver = NotificationObserver( + let registrationObserver = FCMTokenNotificationObserver( notificationCenter: notificationCenter, name: .didRequestRemoteNotificationRegistration ) - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -31,7 +30,7 @@ struct FCMTokenSyncHandlerTests { notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } #expect(registrationObserver.didReceiveNotification) @@ -41,13 +40,13 @@ struct FCMTokenSyncHandlerTests { @Test("foreground 복귀 시 알림 권한이 있으면 APNs 등록을 요청하고 FCM token은 직접 저장하지 않는다") func foreground_복귀_시_알림_권한이_있으면_APNs_등록을_요청하고_FCM_token은_직접_저장하지_않는다() async throws { let notificationCenter = NotificationCenter() - let registrationObserver = NotificationObserver( + let registrationObserver = FCMTokenNotificationObserver( notificationCenter: notificationCenter, name: .didRequestRemoteNotificationRegistration ) - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -57,7 +56,7 @@ struct FCMTokenSyncHandlerTests { notificationCenter.post(name: .didRequestAPNsRegistration, object: nil) - try await waitUntil { + try await waitUntilFCMTokenSync { registrationObserver.didReceiveNotification } try await Task.sleep(for: .milliseconds(100)) @@ -68,9 +67,9 @@ struct FCMTokenSyncHandlerTests { @Test("현재 FCM token 동기화 요청 시 token이 없으면 저장하지 않는다") func 현재_FCM_token_동기화_요청_시_token이_없으면_저장하지_않는다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: nil) + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -88,9 +87,9 @@ struct FCMTokenSyncHandlerTests { @Test("갱신된 FCM token 이벤트 수신 시 저장한다") func 갱신된_FCM_token_이벤트_수신_시_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: nil) - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: nil) + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -104,7 +103,7 @@ struct FCMTokenSyncHandlerTests { userInfo: ["fcmToken": "refreshed-token"] ) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["refreshed-token"] } _ = handler @@ -113,9 +112,9 @@ struct FCMTokenSyncHandlerTests { @Test("APNs token 이벤트 수신 시 APNs token을 적용하고 현재 FCM token을 저장한다") func APNs_token_이벤트_수신_시_APNs_token을_적용하고_현재_FCM_token을_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -130,7 +129,7 @@ struct FCMTokenSyncHandlerTests { userInfo: ["deviceToken": deviceToken] ) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } #expect(messagingService.apnsTokens == [deviceToken]) @@ -140,9 +139,9 @@ struct FCMTokenSyncHandlerTests { @Test("같은 사용자와 같은 FCM token은 한 번만 저장한다") func 같은_사용자와_같은_FCM_token은_한_번만_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -151,7 +150,7 @@ struct FCMTokenSyncHandlerTests { ) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } @@ -165,9 +164,9 @@ struct FCMTokenSyncHandlerTests { @Test("같은 FCM token이어도 사용자가 바뀌면 다시 저장한다") func 같은_FCM_token이어도_사용자가_바뀌면_다시_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy(uid: "first-user") + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy(uid: "first-user") let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -176,13 +175,13 @@ struct FCMTokenSyncHandlerTests { ) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } authService.uid = "second-user" notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token", "current-token"] } _ = handler @@ -191,9 +190,9 @@ struct FCMTokenSyncHandlerTests { @Test("FCM token이 바뀌면 같은 사용자도 다시 저장한다") func FCM_token이_바뀌면_같은_사용자도_다시_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "first-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "first-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -202,20 +201,20 @@ struct FCMTokenSyncHandlerTests { ) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { await userService.updatedFCMTokens == ["first-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["first-token"] } messagingService.currentFCMToken = "second-token" notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { await userService.updatedFCMTokens == ["first-token", "second-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["first-token", "second-token"] } _ = handler } @Test("로그아웃 후 같은 사용자로 다시 로그인하면 같은 FCM token도 다시 저장한다") func 로그아웃_후_같은_사용자로_다시_로그인하면_같은_FCM_token도_다시_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy(uid: "user-id") + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy(uid: "user-id") let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -224,20 +223,20 @@ struct FCMTokenSyncHandlerTests { ) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { await userService.updatedFCMTokens == ["current-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } authService.updateSession(uid: nil) authService.updateSession(uid: "user-id") - try await waitUntil { await userService.updatedFCMTokens == ["current-token", "current-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token", "current-token"] } _ = handler } @Test("FCM token 저장에 실패하면 같은 요청을 다시 저장한다") func FCM_token_저장에_실패하면_같은_요청을_다시_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy(updateError: FCMTokenSyncTestError()) - let authService = AuthServiceSpy() + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy(updateError: FCMTokenSyncTestError()) + let authService = FCMTokenAuthServiceSpy() let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -246,20 +245,20 @@ struct FCMTokenSyncHandlerTests { ) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { await userService.updatedFCMTokens == ["current-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } await userService.setUpdateError(nil) notificationCenter.post(name: .didRequestFCMTokenSync, object: nil) - try await waitUntil { await userService.updatedFCMTokens == ["current-token", "current-token"] } + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token", "current-token"] } _ = handler } @Test("로그인 세션 전이 시 현재 FCM token을 저장한다") func 로그인_세션_전이_시_현재_FCM_token을_저장한다() async throws { let notificationCenter = NotificationCenter() - let messagingService = PushMessagingServiceSpy(currentFCMToken: "current-token") - let userService = UserServiceSpy() - let authService = AuthServiceSpy(uid: nil) + let messagingService = FCMTokenPushMessagingServiceSpy(currentFCMToken: "current-token") + let userService = FCMTokenUserServiceSpy() + let authService = FCMTokenAuthServiceSpy(uid: nil) let handler = FCMTokenSyncHandler( authService: authService, messagingService: messagingService, @@ -269,124 +268,10 @@ struct FCMTokenSyncHandlerTests { authService.updateSession(uid: "user-id") - try await waitUntil { + try await waitUntilFCMTokenSync { await userService.updatedFCMTokens == ["current-token"] } _ = handler } } - -private actor UserServiceSpy: UserService { - private(set) var updatedFCMTokens = [String]() - private var updateError: Error? - - 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 { - updatedFCMTokens.append(fcmToken) - if let updateError { - throw updateError - } - } - - func updateUserTimeZone() async throws { } - - func setUpdateError(_ error: Error?) { - updateError = error - } -} - -private final class AuthServiceSpy: AuthService { - var uid: String? - let providerIDs = [String]() - 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 final class PushMessagingServiceSpy: PushMessagingService { - var currentFCMToken: String? - private(set) var apnsTokens = [Data]() - - init(currentFCMToken: String?) { - self.currentFCMToken = currentFCMToken - } - - func setDelegate(_ delegate: PushMessagingServiceDelegate?) { } - func setAPNSToken(_ deviceToken: Data) { - apnsTokens.append(deviceToken) - } - func isNotificationAuthorized() async -> Bool { true } - - func fetchFCMToken() async throws -> String? { - currentFCMToken - } -} - -private struct FCMTokenSyncTestError: Error { } - -private final class NotificationObserver { - private(set) var didReceiveNotification = false - private var token: NSObjectProtocol? - private let notificationCenter: NotificationCenter - - init(notificationCenter: NotificationCenter, name: Notification.Name) { - self.notificationCenter = notificationCenter - self.token = notificationCenter.addObserver( - forName: name, - object: nil, - queue: nil - ) { [weak self] _ in - self?.didReceiveNotification = true - } - } - - deinit { - if let token { - notificationCenter.removeObserver(token) - } - } -} - -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("조건을 만족하지 못함") -} diff --git a/Application/App/Tests/PushNotification/FCMTokenSyncTestDoubles.swift b/Application/App/Tests/PushNotification/FCMTokenSyncTestDoubles.swift new file mode 100644 index 00000000..3502c2d1 --- /dev/null +++ b/Application/App/Tests/PushNotification/FCMTokenSyncTestDoubles.swift @@ -0,0 +1,129 @@ +// +// FCMTokenSyncTestDoubles.swift +// AppTests +// +// Created by opfic on 7/20/26. +// + +import Combine +import Data +import Foundation +import Testing + +actor FCMTokenUserServiceSpy: UserService { + private(set) var updatedFCMTokenValues = [FCMTokenUpdate]() + private var updateError: Error? + + var updatedFCMTokens: [String] { + updatedFCMTokenValues.map(\.fcmToken) + } + + 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(_ update: FCMTokenUpdate) async throws { + updatedFCMTokenValues.append(update) + if let updateError { + throw updateError + } + } + + func updateUserTimeZone() async throws { } + + func setUpdateError(_ error: Error?) { + updateError = error + } +} + +final class FCMTokenAuthServiceSpy: AuthService { + var uid: String? + let providerIDs = [String]() + 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 { } +} + +final class FCMTokenPushMessagingServiceSpy: PushMessagingService { + var currentFCMToken: String? + private(set) var apnsTokens = [Data]() + + init(currentFCMToken: String?) { + self.currentFCMToken = currentFCMToken + } + + func setDelegate(_ delegate: PushMessagingServiceDelegate?) { } + func setAPNSToken(_ deviceToken: Data) { + apnsTokens.append(deviceToken) + } + func isNotificationAuthorized() async -> Bool { true } + + func fetchFCMToken() async throws -> String? { + currentFCMToken + } +} + +struct FCMTokenSyncTestError: Error { } + +final class FCMTokenNotificationObserver { + private(set) var didReceiveNotification = false + private var token: NSObjectProtocol? + private let notificationCenter: NotificationCenter + + init(notificationCenter: NotificationCenter, name: Notification.Name) { + self.notificationCenter = notificationCenter + self.token = notificationCenter.addObserver( + forName: name, + object: nil, + queue: nil + ) { [weak self] _ in + self?.didReceiveNotification = true + } + } + + deinit { + if let token { + notificationCenter.removeObserver(token) + } + } +} + +func waitUntilFCMTokenSync( + 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("조건을 만족하지 못함") +} diff --git a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift index 9474cd37..ce8ab316 100644 --- a/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift +++ b/Application/App/Tests/UserTimeZone/UserTimeZoneSyncHandlerTests.swift @@ -198,7 +198,7 @@ private actor UserServiceSpy: UserService { 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 updateFCMToken(_ update: FCMTokenUpdate) async throws { } func updateUserTimeZone() async throws { updateUserTimeZoneCallCount += 1 diff --git a/Application/Data/Sources/DTO/AuthDataResponse.swift b/Application/Data/Sources/DTO/AuthDataResponse.swift index 5721ebe8..1351cc46 100644 --- a/Application/Data/Sources/DTO/AuthDataResponse.swift +++ b/Application/Data/Sources/DTO/AuthDataResponse.swift @@ -14,21 +14,18 @@ public struct AuthDataResponse { public let email: String? public let providers: [String] public let providerID: String - public let fcmToken: String? public init( uid: String, displayName: String?, email: String?, providers: [String], - providerID: String, - fcmToken: String? = nil + providerID: String ) { self.uid = uid self.displayName = displayName self.email = email self.providers = providers self.providerID = providerID - self.fcmToken = fcmToken } } diff --git a/Application/Data/Sources/DTO/FCMTokenUpdate.swift b/Application/Data/Sources/DTO/FCMTokenUpdate.swift new file mode 100644 index 00000000..06435911 --- /dev/null +++ b/Application/Data/Sources/DTO/FCMTokenUpdate.swift @@ -0,0 +1,33 @@ +// +// FCMTokenUpdate.swift +// Data +// +// Created by opfic on 7/20/26. +// + +public struct FCMTokenUpdate: Equatable, Sendable { + public let fcmToken: String + public let code: PushLanguageCode + + public init( + fcmToken: String, + code: PushLanguageCode + ) { + self.fcmToken = fcmToken + self.code = code + } +} + +public enum PushLanguageCode: String, Equatable, Sendable { + case korean = "ko" + case english = "en" + + public init(identifier: String?) { + let languageCode = identifier? + .split(whereSeparator: { $0 == "-" || $0 == "_" }) + .first? + .lowercased() + + self = languageCode == Self.english.rawValue ? .english : .korean + } +} diff --git a/Application/Data/Sources/Protocol/UserService.swift b/Application/Data/Sources/Protocol/UserService.swift index 06d09398..3c7df1ba 100644 --- a/Application/Data/Sources/Protocol/UserService.swift +++ b/Application/Data/Sources/Protocol/UserService.swift @@ -11,6 +11,6 @@ public protocol UserService { func upsertUser(_ response: AuthDataResponse) async throws func fetchUserProfile() async throws -> UserProfileResponse func upsertStatusMessage(_ message: String) async throws - func updateFCMToken(_ fcmToken: String) async throws + func updateFCMToken(_ update: FCMTokenUpdate) async throws func updateUserTimeZone() async throws } diff --git a/Application/Data/Tests/DTO/FCMTokenUpdateTests.swift b/Application/Data/Tests/DTO/FCMTokenUpdateTests.swift new file mode 100644 index 00000000..2fe64aca --- /dev/null +++ b/Application/Data/Tests/DTO/FCMTokenUpdateTests.swift @@ -0,0 +1,29 @@ +// +// FCMTokenUpdateTests.swift +// DataTests +// +// Created by opfic on 7/20/26. +// + +import Testing +@testable import Data + +struct FCMTokenUpdateTests { + @Test("한국어 localization identifier를 한국어 코드로 정규화한다") + func pushLanguageCode_한국어_identifier를_한국어_코드로_정규화한다() { + #expect(PushLanguageCode(identifier: "ko") == .korean) + #expect(PushLanguageCode(identifier: "ko-KR") == .korean) + } + + @Test("영어 localization identifier를 영어 코드로 정규화한다") + func pushLanguageCode_영어_identifier를_영어_코드로_정규화한다() { + #expect(PushLanguageCode(identifier: "en") == .english) + #expect(PushLanguageCode(identifier: "en_US") == .english) + } + + @Test("누락되거나 지원하지 않는 localization identifier는 한국어 코드로 정규화한다") + func pushLanguageCode_누락되거나_지원하지_않는_identifier는_한국어_코드로_정규화한다() { + #expect(PushLanguageCode(identifier: nil) == .korean) + #expect(PushLanguageCode(identifier: "ja") == .korean) + } +} diff --git a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift index 02eaecf8..c2f7868f 100644 --- a/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AuthenticationRepositoryImplTests.swift @@ -332,7 +332,7 @@ private actor AuthenticationRepositoryUserServiceSpy: UserService { } func upsertStatusMessage(_ message: String) async throws { } - func updateFCMToken(_ fcmToken: String) async throws { } + func updateFCMToken(_ update: FCMTokenUpdate) async throws { } func updateUserTimeZone() async throws { } } diff --git a/Application/Infra/Sources/Mapper/FCMTokenUpdateMapping.swift b/Application/Infra/Sources/Mapper/FCMTokenUpdateMapping.swift new file mode 100644 index 00000000..6e5e8306 --- /dev/null +++ b/Application/Infra/Sources/Mapper/FCMTokenUpdateMapping.swift @@ -0,0 +1,22 @@ +// +// FCMTokenUpdateMapping.swift +// Infra +// +// Created by opfic on 7/20/26. +// + +import Data + +extension FCMTokenUpdate { + var firestoreData: [String: Any] { + [ + FCMTokenFieldKey.fcmToken.rawValue: fcmToken, + FCMTokenFieldKey.pushLanguageCode.rawValue: code.rawValue + ] + } +} + +private enum FCMTokenFieldKey: String { + case fcmToken + case pushLanguageCode +} diff --git a/Application/Infra/Sources/Service/UserServiceImpl.swift b/Application/Infra/Sources/Service/UserServiceImpl.swift index 55b6c2d5..65cadd3f 100644 --- a/Application/Infra/Sources/Service/UserServiceImpl.swift +++ b/Application/Infra/Sources/Service/UserServiceImpl.swift @@ -56,16 +56,9 @@ final class UserServiceImpl: UserService { userField["appleName"] = user.displayName } - var tokenField: [String: Any] = [:] - - if let fcmToken = response.fcmToken { - tokenField["fcmToken"] = fcmToken - } - try await upsertUserDocuments( uid: user.uid, - userField: userField, - tokenField: tokenField + userField: userField ) logger.info("Successfully upserted user: \(user.uid)") @@ -134,7 +127,7 @@ final class UserServiceImpl: UserService { } } - func updateFCMToken(_ fcmToken: String) async throws { + func updateFCMToken(_ update: FCMTokenUpdate) async throws { guard let uid = Auth.auth().currentUser?.uid else { logger.info("Skipping FCM token update because no authenticated user exists") return @@ -144,7 +137,7 @@ final class UserServiceImpl: UserService { do { let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try await tokensRef.setData(["fcmToken": fcmToken], merge: true) + try await tokensRef.setData(update.firestoreData, merge: true) logger.info("Successfully updated FCM token") } catch { logger.error("Failed to update FCM token", error: error) @@ -191,12 +184,10 @@ private extension UserServiceImpl { func upsertUserDocuments( uid: String, - userField: [String: Any], - tokenField: [String: Any] + userField: [String: Any] ) async throws { let userRef = store.document(FirestorePath.user(uid)) let infoRef = store.document(FirestorePath.userData(uid, document: .info)) - let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) let settingsRef = store.document(FirestorePath.userData(uid, document: .settings)) let todoCounterRef = store.document(FirestorePath.counter(uid, document: .todo)) @@ -234,10 +225,6 @@ private extension UserServiceImpl { ) transaction.setData(infoField, forDocument: infoRef, merge: true) - if !tokenField.isEmpty { - transaction.setData(tokenField, forDocument: tokensRef, merge: true) - } - transaction.setData(settingsField, forDocument: settingsRef, merge: true) if !userDocument.exists { diff --git a/Application/Infra/Tests/Mapper/FCMTokenUpdateMappingTests.swift b/Application/Infra/Tests/Mapper/FCMTokenUpdateMappingTests.swift new file mode 100644 index 00000000..4c8158f7 --- /dev/null +++ b/Application/Infra/Tests/Mapper/FCMTokenUpdateMappingTests.swift @@ -0,0 +1,27 @@ +// +// FCMTokenUpdateMappingTests.swift +// InfraTests +// +// Created by opfic on 7/20/26. +// + +import Data +import Testing +@testable import Infra + +struct FCMTokenUpdateMappingTests { + @Test("FCM token 저장 데이터는 token과 앱 언어 코드만 포함한다") + func firestoreData_FCM_token과_앱_언어_코드만_포함한다() { + let update = FCMTokenUpdate( + fcmToken: "fcm-token", + code: .english + ) + + let data = update.firestoreData + + #expect(data.count == 2) + #expect(data["fcmToken"] as? String == "fcm-token") + #expect(data["pushLanguageCode"] as? String == "en") + #expect(data["pushLocalizationVersion"] == nil) + } +}