From 93f95e03cbc3372614b849877bcf6de1bdea69ef Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 20:11:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20FCM=20=EB=93=B1=EB=A1=9D=EC=9A=A9?= =?UTF-8?q?=20deviceId=20Keychain=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Device/DeviceIdProviding.swift | 3 ++ .../Sources/Device/DeviceIdStore.swift | 33 +++++++++++++++++++ .../Utils/Sources/Key/KeychainKey.swift | 1 + 3 files changed, 37 insertions(+) create mode 100644 Projects/Core/Storage/Sources/Device/DeviceIdProviding.swift create mode 100644 Projects/Core/Storage/Sources/Device/DeviceIdStore.swift diff --git a/Projects/Core/Storage/Sources/Device/DeviceIdProviding.swift b/Projects/Core/Storage/Sources/Device/DeviceIdProviding.swift new file mode 100644 index 00000000..1773998b --- /dev/null +++ b/Projects/Core/Storage/Sources/Device/DeviceIdProviding.swift @@ -0,0 +1,3 @@ +public protocol DeviceIdProviding: Sendable { + func deviceId() async throws -> String +} diff --git a/Projects/Core/Storage/Sources/Device/DeviceIdStore.swift b/Projects/Core/Storage/Sources/Device/DeviceIdStore.swift new file mode 100644 index 00000000..d3b91dfe --- /dev/null +++ b/Projects/Core/Storage/Sources/Device/DeviceIdStore.swift @@ -0,0 +1,33 @@ +import Foundation +import Utils + +public actor DeviceIdStore: DeviceIdProviding { + private let keychain: KeychainStorage + private var cachedDeviceId: String? + + public init(keychain: KeychainStorage) { + self.keychain = keychain + } + + public func deviceId() async throws -> String { + if let cachedDeviceId { + return cachedDeviceId + } + + do { + if let stored: String = try await keychain.get(forKey: KeychainKey.deviceId) { + cachedDeviceId = stored + return stored + } + } catch KeychainError.itemNotFound { + // 저장된 ID가 없을 때만 새 ID를 발급합니다. + } catch { + throw error + } + + let newDeviceId = UUID().uuidString + try await keychain.save(newDeviceId, forKey: KeychainKey.deviceId) + cachedDeviceId = newDeviceId + return newDeviceId + } +} diff --git a/Projects/Shared/Utils/Sources/Key/KeychainKey.swift b/Projects/Shared/Utils/Sources/Key/KeychainKey.swift index 8eabad50..33057132 100644 --- a/Projects/Shared/Utils/Sources/Key/KeychainKey.swift +++ b/Projects/Shared/Utils/Sources/Key/KeychainKey.swift @@ -1,4 +1,5 @@ public enum KeychainKey: String { case accessToken = "access_token" case refreshToken = "refresh_token" + case deviceId = "device_id" } From 3dcdeb1effbc20337542c062387be55ffc6c8ae2 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 20:11:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20Domain?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B0=8F=20UseCase=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/FCM/Error/FCMError.swift | 18 ++++++++ .../Sources/FCM/Model/FCMDeviceType.swift | 3 ++ .../Sources/FCM/Model/FCMRegistration.swift | 24 ++++++++++ .../FCM/Repository/FCMRepository.swift | 4 ++ .../UseCase/DeactivateFCMTokenUseCase.swift | 15 ++++++ .../FCM/UseCase/RegisterFCMTokenUseCase.swift | 15 ++++++ .../Tests/FCM/Mocks/MockFCMRepository.swift | 32 +++++++++++++ .../DeactivateFCMTokenUseCaseTests.swift | 32 +++++++++++++ .../RegisterFCMTokenUseCaseTests.swift | 46 +++++++++++++++++++ 9 files changed, 189 insertions(+) create mode 100644 Projects/Domain/Sources/FCM/Error/FCMError.swift create mode 100644 Projects/Domain/Sources/FCM/Model/FCMDeviceType.swift create mode 100644 Projects/Domain/Sources/FCM/Model/FCMRegistration.swift create mode 100644 Projects/Domain/Sources/FCM/Repository/FCMRepository.swift create mode 100644 Projects/Domain/Sources/FCM/UseCase/DeactivateFCMTokenUseCase.swift create mode 100644 Projects/Domain/Sources/FCM/UseCase/RegisterFCMTokenUseCase.swift create mode 100644 Projects/Domain/Tests/FCM/Mocks/MockFCMRepository.swift create mode 100644 Projects/Domain/Tests/FCM/UseCase/DeactivateFCMTokenUseCaseTests.swift create mode 100644 Projects/Domain/Tests/FCM/UseCase/RegisterFCMTokenUseCaseTests.swift diff --git a/Projects/Domain/Sources/FCM/Error/FCMError.swift b/Projects/Domain/Sources/FCM/Error/FCMError.swift new file mode 100644 index 00000000..fb8be810 --- /dev/null +++ b/Projects/Domain/Sources/FCM/Error/FCMError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum FCMError: Error, LocalizedError { + case serverError + case networkError + case unknown + + public var errorDescription: String? { + switch self { + case .serverError: + "서버에 문제가 발생했어요. 잠시 후 다시 시도해주세요." + case .networkError: + "네트워크 연결이 원활하지 않아요. 인터넷 상태를 확인해주세요." + case .unknown: + "알 수 없는 오류가 발생했어요. 다시 시도해주세요." + } + } +} diff --git a/Projects/Domain/Sources/FCM/Model/FCMDeviceType.swift b/Projects/Domain/Sources/FCM/Model/FCMDeviceType.swift new file mode 100644 index 00000000..5a3c1531 --- /dev/null +++ b/Projects/Domain/Sources/FCM/Model/FCMDeviceType.swift @@ -0,0 +1,3 @@ +public enum FCMDeviceType: String, Sendable { + case ios = "IOS" +} diff --git a/Projects/Domain/Sources/FCM/Model/FCMRegistration.swift b/Projects/Domain/Sources/FCM/Model/FCMRegistration.swift new file mode 100644 index 00000000..b08dbf25 --- /dev/null +++ b/Projects/Domain/Sources/FCM/Model/FCMRegistration.swift @@ -0,0 +1,24 @@ +public struct FCMRegistration: Sendable { + public let token: String + public let deviceId: String + public let deviceType: FCMDeviceType + public let deviceModel: String? + public let osVersion: String? + public let appVersion: String? + + public init( + token: String, + deviceId: String, + deviceType: FCMDeviceType, + deviceModel: String?, + osVersion: String?, + appVersion: String? + ) { + self.token = token + self.deviceId = deviceId + self.deviceType = deviceType + self.deviceModel = deviceModel + self.osVersion = osVersion + self.appVersion = appVersion + } +} diff --git a/Projects/Domain/Sources/FCM/Repository/FCMRepository.swift b/Projects/Domain/Sources/FCM/Repository/FCMRepository.swift new file mode 100644 index 00000000..d48ceb60 --- /dev/null +++ b/Projects/Domain/Sources/FCM/Repository/FCMRepository.swift @@ -0,0 +1,4 @@ +public protocol FCMRepository { + func register(_ registration: FCMRegistration) async throws + func deactivate() async throws +} diff --git a/Projects/Domain/Sources/FCM/UseCase/DeactivateFCMTokenUseCase.swift b/Projects/Domain/Sources/FCM/UseCase/DeactivateFCMTokenUseCase.swift new file mode 100644 index 00000000..27305f32 --- /dev/null +++ b/Projects/Domain/Sources/FCM/UseCase/DeactivateFCMTokenUseCase.swift @@ -0,0 +1,15 @@ +public protocol DeactivateFCMTokenUseCase { + func execute() async throws +} + +public final class DefaultDeactivateFCMTokenUseCase: DeactivateFCMTokenUseCase { + private let fcmRepository: FCMRepository + + public init(fcmRepository: FCMRepository) { + self.fcmRepository = fcmRepository + } + + public func execute() async throws { + try await fcmRepository.deactivate() + } +} diff --git a/Projects/Domain/Sources/FCM/UseCase/RegisterFCMTokenUseCase.swift b/Projects/Domain/Sources/FCM/UseCase/RegisterFCMTokenUseCase.swift new file mode 100644 index 00000000..17b2a57b --- /dev/null +++ b/Projects/Domain/Sources/FCM/UseCase/RegisterFCMTokenUseCase.swift @@ -0,0 +1,15 @@ +public protocol RegisterFCMTokenUseCase { + func execute(registration: FCMRegistration) async throws +} + +public final class DefaultRegisterFCMTokenUseCase: RegisterFCMTokenUseCase { + private let fcmRepository: FCMRepository + + public init(fcmRepository: FCMRepository) { + self.fcmRepository = fcmRepository + } + + public func execute(registration: FCMRegistration) async throws { + try await fcmRepository.register(registration) + } +} diff --git a/Projects/Domain/Tests/FCM/Mocks/MockFCMRepository.swift b/Projects/Domain/Tests/FCM/Mocks/MockFCMRepository.swift new file mode 100644 index 00000000..449de79f --- /dev/null +++ b/Projects/Domain/Tests/FCM/Mocks/MockFCMRepository.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import Domain + +final class MockFCMRepository: FCMRepository { + var registerError: Error? + var deactivateError: Error? + + private(set) var registerCallCount = 0 + private(set) var lastRegistration: FCMRegistration? + private(set) var deactivateCallCount = 0 + + func register(_ registration: FCMRegistration) async throws { + registerCallCount += 1 + lastRegistration = registration + + if let registerError { + throw registerError + } + } + + func deactivate() async throws { + deactivateCallCount += 1 + + if let deactivateError { + throw deactivateError + } + } +} + +enum MockFCMError: Error { + case failed +} diff --git a/Projects/Domain/Tests/FCM/UseCase/DeactivateFCMTokenUseCaseTests.swift b/Projects/Domain/Tests/FCM/UseCase/DeactivateFCMTokenUseCaseTests.swift new file mode 100644 index 00000000..6f732661 --- /dev/null +++ b/Projects/Domain/Tests/FCM/UseCase/DeactivateFCMTokenUseCaseTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import Domain + +final class DeactivateFCMTokenUseCaseTests: XCTestCase { + private func makeSUT() -> (MockFCMRepository, DefaultDeactivateFCMTokenUseCase) { + let mockRepository = MockFCMRepository() + let sut = DefaultDeactivateFCMTokenUseCase(fcmRepository: mockRepository) + return (mockRepository, sut) + } + + func test_정상호출_토큰비활성화() async throws { + let (mockRepository, sut) = makeSUT() + + try await sut.execute() + + XCTAssertEqual(mockRepository.deactivateCallCount, 1) + } + + func test_비활성화실패_에러전파() async { + let (mockRepository, sut) = makeSUT() + mockRepository.deactivateError = MockFCMError.failed + + do { + try await sut.execute() + XCTFail("에러가 발생해야 합니다") + } catch { + XCTAssertEqual(error as? MockFCMError, .failed) + } + + XCTAssertEqual(mockRepository.deactivateCallCount, 1) + } +} diff --git a/Projects/Domain/Tests/FCM/UseCase/RegisterFCMTokenUseCaseTests.swift b/Projects/Domain/Tests/FCM/UseCase/RegisterFCMTokenUseCaseTests.swift new file mode 100644 index 00000000..2dc9cf1a --- /dev/null +++ b/Projects/Domain/Tests/FCM/UseCase/RegisterFCMTokenUseCaseTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import Domain + +final class RegisterFCMTokenUseCaseTests: XCTestCase { + private func makeSUT() -> (MockFCMRepository, DefaultRegisterFCMTokenUseCase) { + let mockRepository = MockFCMRepository() + let sut = DefaultRegisterFCMTokenUseCase(fcmRepository: mockRepository) + return (mockRepository, sut) + } + + private func makeRegistration() -> FCMRegistration { + FCMRegistration( + token: "fcm-token", + deviceId: "device-id", + deviceType: .ios, + deviceModel: "iPhone15,2", + osVersion: "17.0", + appVersion: "1.2.0" + ) + } + + func test_정상호출_토큰등록() async throws { + let (mockRepository, sut) = makeSUT() + let registration = makeRegistration() + + try await sut.execute(registration: registration) + + XCTAssertEqual(mockRepository.registerCallCount, 1) + XCTAssertEqual(mockRepository.lastRegistration?.token, registration.token) + XCTAssertEqual(mockRepository.lastRegistration?.deviceId, registration.deviceId) + } + + func test_등록실패_에러전파() async { + let (mockRepository, sut) = makeSUT() + mockRepository.registerError = MockFCMError.failed + + do { + try await sut.execute(registration: makeRegistration()) + XCTFail("에러가 발생해야 합니다") + } catch { + XCTAssertEqual(error as? MockFCMError, .failed) + } + + XCTAssertEqual(mockRepository.registerCallCount, 1) + } +} From d0980a81eae4be3967246b5da087060cd3686d49 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 20:11:34 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20Data=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B0=8F=20Factory=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/FCM/DTO/FCMRequest.swift | 8 +++ .../Sources/FCM/DTO/FCMRequestMapper.swift | 14 +++++ .../FCM/DataSource/FCMRemoteDataSource.swift | 31 ++++++++++ .../Sources/FCM/Endpoint/FCMEndpoint.swift | 60 +++++++++++++++++++ .../FCM/Repository/DefaultFCMRepository.swift | 52 ++++++++++++++++ .../Sources/Util/Factory/DataFactory.swift | 23 +++++++ .../Sources/Util/Factory/DataFactory.swift | 1 + .../Util/Factory/DomainFactory+FCM.swift | 19 ++++++ .../Sources/Util/Factory/DomainFactory.swift | 3 +- 9 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 Projects/Data/Sources/FCM/DTO/FCMRequest.swift create mode 100644 Projects/Data/Sources/FCM/DTO/FCMRequestMapper.swift create mode 100644 Projects/Data/Sources/FCM/DataSource/FCMRemoteDataSource.swift create mode 100644 Projects/Data/Sources/FCM/Endpoint/FCMEndpoint.swift create mode 100644 Projects/Data/Sources/FCM/Repository/DefaultFCMRepository.swift create mode 100644 Projects/Domain/Sources/Util/Factory/DomainFactory+FCM.swift diff --git a/Projects/Data/Sources/FCM/DTO/FCMRequest.swift b/Projects/Data/Sources/FCM/DTO/FCMRequest.swift new file mode 100644 index 00000000..3fdcce7e --- /dev/null +++ b/Projects/Data/Sources/FCM/DTO/FCMRequest.swift @@ -0,0 +1,8 @@ +struct RegisterFCMTokenRequest: Encodable { + let token: String + let deviceId: String + let deviceType: String + let deviceModel: String? + let osVersion: String? + let appVersion: String? +} diff --git a/Projects/Data/Sources/FCM/DTO/FCMRequestMapper.swift b/Projects/Data/Sources/FCM/DTO/FCMRequestMapper.swift new file mode 100644 index 00000000..9f32423c --- /dev/null +++ b/Projects/Data/Sources/FCM/DTO/FCMRequestMapper.swift @@ -0,0 +1,14 @@ +import Domain + +enum FCMRequestMapper { + static func toRegisterRequest(_ registration: FCMRegistration) -> RegisterFCMTokenRequest { + RegisterFCMTokenRequest( + token: registration.token, + deviceId: registration.deviceId, + deviceType: registration.deviceType.rawValue, + deviceModel: registration.deviceModel, + osVersion: registration.osVersion, + appVersion: registration.appVersion + ) + } +} diff --git a/Projects/Data/Sources/FCM/DataSource/FCMRemoteDataSource.swift b/Projects/Data/Sources/FCM/DataSource/FCMRemoteDataSource.swift new file mode 100644 index 00000000..ab73824c --- /dev/null +++ b/Projects/Data/Sources/FCM/DataSource/FCMRemoteDataSource.swift @@ -0,0 +1,31 @@ +import Networking + +protocol FCMRemoteDataSource { + func register(request: RegisterFCMTokenRequest) async throws + func deactivate(deviceId: String) async throws +} + +final class DefaultFCMRemoteDataSource: FCMRemoteDataSource { + private let authed: NetworkClient + + init(authed: NetworkClient) { + self.authed = authed + } + + func register(request: RegisterFCMTokenRequest) async throws { + let endpoint = FCMEndpoint.register( + token: request.token, + deviceId: request.deviceId, + deviceType: request.deviceType, + deviceModel: request.deviceModel, + osVersion: request.osVersion, + appVersion: request.appVersion + ) + let _: EmptyResponse = try await authed.request(endpoint) + } + + func deactivate(deviceId: String) async throws { + let endpoint = FCMEndpoint.deactivate(deviceId: deviceId) + let _: EmptyResponse = try await authed.request(endpoint) + } +} diff --git a/Projects/Data/Sources/FCM/Endpoint/FCMEndpoint.swift b/Projects/Data/Sources/FCM/Endpoint/FCMEndpoint.swift new file mode 100644 index 00000000..f2d30172 --- /dev/null +++ b/Projects/Data/Sources/FCM/Endpoint/FCMEndpoint.swift @@ -0,0 +1,60 @@ +import Foundation +import Networking + +public enum FCMEndpoint { + case register( + token: String, + deviceId: String, + deviceType: String, + deviceModel: String?, + osVersion: String?, + appVersion: String? + ) + case deactivate(deviceId: String) +} + +extension FCMEndpoint: APIEndpoint { + public var path: String { + "/api/users/me/fcm-tokens" + } + + public var method: HTTPMethod { + switch self { + case .register: + .post + case .deactivate: + .delete + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } + + public var parameters: [String: Any]? { + switch self { + case .register: + nil + case let .deactivate(deviceId): + ["deviceId": deviceId] + } + } + + public var body: Data? { + switch self { + case let .register(token, deviceId, deviceType, deviceModel, osVersion, appVersion): + let request = RegisterFCMTokenRequest( + token: token, + deviceId: deviceId, + deviceType: deviceType, + deviceModel: deviceModel, + osVersion: osVersion, + appVersion: appVersion + ) + return try? JSONEncoder().encode(request) + + case .deactivate: + return nil + } + } +} diff --git a/Projects/Data/Sources/FCM/Repository/DefaultFCMRepository.swift b/Projects/Data/Sources/FCM/Repository/DefaultFCMRepository.swift new file mode 100644 index 00000000..6633aebd --- /dev/null +++ b/Projects/Data/Sources/FCM/Repository/DefaultFCMRepository.swift @@ -0,0 +1,52 @@ +import Domain +import Networking +import Storage + +final class DefaultFCMRepository: FCMRepository { + private let fcmRemote: FCMRemoteDataSource + private let deviceIdStore: DeviceIdProviding + + init( + fcmRemote: FCMRemoteDataSource, + deviceIdStore: DeviceIdProviding + ) { + self.fcmRemote = fcmRemote + self.deviceIdStore = deviceIdStore + } + + func register(_ registration: FCMRegistration) async throws { + do { + let request = FCMRequestMapper.toRegisterRequest(registration) + try await fcmRemote.register(request: request) + } catch { + throw mapToDomainError(error) + } + } + + func deactivate() async throws { + do { + let deviceId = try await deviceIdStore.deviceId() + try await fcmRemote.deactivate(deviceId: deviceId) + } catch { + throw mapToDomainError(error) + } + } +} + +// MARK: - Private + +private extension DefaultFCMRepository { + func mapToDomainError(_ error: Error) -> FCMError { + if let networkError = error as? NetworkError { + switch networkError { + case .serverError: + return .serverError + case .unauthorized, .invalidURL, .invalidResponse, + .invalidEndpointType, .unknown, .encodingError, .decodingError, + .noData: + return .networkError + } + } + return .unknown + } +} diff --git a/Projects/Data/Sources/Util/Factory/DataFactory.swift b/Projects/Data/Sources/Util/Factory/DataFactory.swift index bb60cb0d..a0703555 100644 --- a/Projects/Data/Sources/Util/Factory/DataFactory.swift +++ b/Projects/Data/Sources/Util/Factory/DataFactory.swift @@ -204,6 +204,25 @@ public final class DefaultDataFactory: DataFactory { ) }() + // MARK: - FCM + + private lazy var cachedDeviceIdStore = DeviceIdStore( + keychain: keychainFactory.makeKeychainStorage() + ) + + private lazy var cachedFCMRepository: FCMRepository = { + let authedClient = networkFactory.makeAuthedClient(cachedTokenRefresher) + + let fcmRemoteDataSource = DefaultFCMRemoteDataSource( + authed: authedClient + ) + + return DefaultFCMRepository( + fcmRemote: fcmRemoteDataSource, + deviceIdStore: cachedDeviceIdStore + ) + }() + // MARK: - Public public func makeAuthRepository() -> AuthRepository { @@ -237,4 +256,8 @@ public final class DefaultDataFactory: DataFactory { public func makeWishlistRepository() -> WishlistRepository { cachedWishlistRepository } + + public func makeFCMRepository() -> FCMRepository { + cachedFCMRepository + } } diff --git a/Projects/Domain/Sources/Util/Factory/DataFactory.swift b/Projects/Domain/Sources/Util/Factory/DataFactory.swift index d1b77b23..438d71c6 100644 --- a/Projects/Domain/Sources/Util/Factory/DataFactory.swift +++ b/Projects/Domain/Sources/Util/Factory/DataFactory.swift @@ -7,4 +7,5 @@ public protocol DataFactory: AnyObject { func makeUserRepository() -> UserRepository func makeNoticeRepository() -> NoticeRepository func makeWishlistRepository() -> WishlistRepository + func makeFCMRepository() -> FCMRepository } diff --git a/Projects/Domain/Sources/Util/Factory/DomainFactory+FCM.swift b/Projects/Domain/Sources/Util/Factory/DomainFactory+FCM.swift new file mode 100644 index 00000000..af822eac --- /dev/null +++ b/Projects/Domain/Sources/Util/Factory/DomainFactory+FCM.swift @@ -0,0 +1,19 @@ +public protocol DomainFCMFactory: AnyObject { + func makeFCMRepository() -> FCMRepository + func makeRegisterFCMTokenUseCase() -> RegisterFCMTokenUseCase + func makeDeactivateFCMTokenUseCase() -> DeactivateFCMTokenUseCase +} + +public extension DefaultDomainFactory { + func makeFCMRepository() -> FCMRepository { + factory.makeFCMRepository() + } + + func makeRegisterFCMTokenUseCase() -> RegisterFCMTokenUseCase { + DefaultRegisterFCMTokenUseCase(fcmRepository: makeFCMRepository()) + } + + func makeDeactivateFCMTokenUseCase() -> DeactivateFCMTokenUseCase { + DefaultDeactivateFCMTokenUseCase(fcmRepository: makeFCMRepository()) + } +} diff --git a/Projects/Domain/Sources/Util/Factory/DomainFactory.swift b/Projects/Domain/Sources/Util/Factory/DomainFactory.swift index 08a5c76b..2447529a 100644 --- a/Projects/Domain/Sources/Util/Factory/DomainFactory.swift +++ b/Projects/Domain/Sources/Util/Factory/DomainFactory.swift @@ -7,7 +7,8 @@ public protocol DomainFactory: DomainDiscoveryFactory, DomainUserFactory, DomainNoticeFactory, - DomainWishlistFactory {} + DomainWishlistFactory, + DomainFCMFactory {} public final class DefaultDomainFactory: DomainFactory { let factory: DataFactory From b3fde3de53f0bff1c01d316265a075d7b297da85 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 20:11:37 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20App=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20AppContainer=20DI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/AppDelegate.swift | 11 +-- .../App/Sources/Factory/AppContainer.swift | 28 ++++++ Projects/App/Sources/Factory/AppFactory.swift | 13 +++ .../Sources/Push/DefaultPushTokenSyncer.swift | 85 +++++++++++++++++++ .../App/Sources/Push/DeviceInfoProvider.swift | 29 +++++++ .../App/Sources/Push/PushTokenSyncer.swift | 20 ----- Projects/App/Sources/SceneDelegate.swift | 8 +- 7 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 Projects/App/Sources/Factory/AppContainer.swift create mode 100644 Projects/App/Sources/Push/DefaultPushTokenSyncer.swift create mode 100644 Projects/App/Sources/Push/DeviceInfoProvider.swift delete mode 100644 Projects/App/Sources/Push/PushTokenSyncer.swift diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 557d5df8..ffd32e75 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -4,14 +4,15 @@ import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { - private let fcmTokenProvider: FCMTokenProviding = DefaultFCMFactory.shared.makeFCMTokenProvider() - private let pushTokenSyncer: PushTokenSyncing = DeferredPushTokenSyncer() private var pushTokenSyncTask: Task? func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + AppContainer.bootstrap(config: AppConfiguration()) + + let fcmTokenProvider = AppContainer.shared.factory.fcmTokenProvider fcmTokenProvider.configure() observeFCMTokenUpdates() return true @@ -21,7 +22,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - fcmTokenProvider.setAPNSToken(deviceToken) + AppContainer.shared.factory.fcmTokenProvider.setAPNSToken(deviceToken) } func application( @@ -37,8 +38,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { private func observeFCMTokenUpdates() { pushTokenSyncTask?.cancel() - let tokenUpdates = fcmTokenProvider.fcmTokenUpdates() - let pushTokenSyncer = pushTokenSyncer + let tokenUpdates = AppContainer.shared.factory.fcmTokenProvider.fcmTokenUpdates() + let pushTokenSyncer = AppContainer.shared.pushTokenSyncer pushTokenSyncTask = Task { for await token in tokenUpdates { diff --git a/Projects/App/Sources/Factory/AppContainer.swift b/Projects/App/Sources/Factory/AppContainer.swift new file mode 100644 index 00000000..cfe01ca1 --- /dev/null +++ b/Projects/App/Sources/Factory/AppContainer.swift @@ -0,0 +1,28 @@ +final class AppContainer { + private static var stored: AppContainer? + + static var shared: AppContainer { + guard let stored else { + fatalError("AppContainer.bootstrap(config:)를 먼저 호출해야 합니다.") + } + return stored + } + + let factory: AppFactory + + private lazy var pushTokenSyncerInstance: PushTokenSyncing = factory.makePushTokenSyncer() + + var pushTokenSyncer: PushTokenSyncing { + pushTokenSyncerInstance + } + + private init(config: AppConfiguration) { + factory = AppFactory(config: config) + } + + static func bootstrap(config: AppConfiguration) { + guard stored == nil else { return } + + stored = AppContainer(config: config) + } +} diff --git a/Projects/App/Sources/Factory/AppFactory.swift b/Projects/App/Sources/Factory/AppFactory.swift index bb516672..1b7de301 100644 --- a/Projects/App/Sources/Factory/AppFactory.swift +++ b/Projects/App/Sources/Factory/AppFactory.swift @@ -53,4 +53,17 @@ final class AppFactory { self.dataFactory = dataFactory self.domainFactory = domainFactory } + + func makePushTokenSyncer() -> PushTokenSyncing { + let deviceIdStore = DeviceIdStore( + keychain: keychainFactory.makeKeychainStorage() + ) + + return DefaultPushTokenSyncer( + registerFCMToken: domainFactory.makeRegisterFCMTokenUseCase(), + checkFullAuthentication: domainFactory.makeCheckFullAuthenticationUseCase(), + fcmTokenProvider: fcmTokenProvider, + deviceIdStore: deviceIdStore + ) + } } diff --git a/Projects/App/Sources/Push/DefaultPushTokenSyncer.swift b/Projects/App/Sources/Push/DefaultPushTokenSyncer.swift new file mode 100644 index 00000000..35cb23c7 --- /dev/null +++ b/Projects/App/Sources/Push/DefaultPushTokenSyncer.swift @@ -0,0 +1,85 @@ +import Domain +import FCM +import Logger +import Storage + +protocol PushTokenSyncing: AnyObject { + func sync(fcmToken: String) async + func syncCurrentTokenIfAuthenticated() async +} + +actor DefaultPushTokenSyncer: PushTokenSyncing { + private let registerFCMToken: RegisterFCMTokenUseCase + private let checkFullAuthentication: CheckFullAuthenticationUseCase + private let fcmTokenProvider: FCMTokenProviding + private let deviceIdStore: DeviceIdProviding + + private struct RegistrationKey: Hashable { + let token: String + let deviceId: String + } + + private var inFlightRegistrations = Set() + + init( + registerFCMToken: RegisterFCMTokenUseCase, + checkFullAuthentication: CheckFullAuthenticationUseCase, + fcmTokenProvider: FCMTokenProviding, + deviceIdStore: DeviceIdProviding + ) { + self.registerFCMToken = registerFCMToken + self.checkFullAuthentication = checkFullAuthentication + self.fcmTokenProvider = fcmTokenProvider + self.deviceIdStore = deviceIdStore + } + + func sync(fcmToken: String) async { + await registerIfNeeded(fcmToken: fcmToken) + } + + func syncCurrentTokenIfAuthenticated() async { + guard let fcmToken = await fcmTokenProvider.currentToken() else { return } + + await registerIfNeeded(fcmToken: fcmToken) + } + + private func registerIfNeeded(fcmToken: String) async { + guard await checkFullAuthentication.execute() else { return } + + let deviceId: String + do { + deviceId = try await deviceIdStore.deviceId() + } catch { + Logger.shared.error( + "FCM 토큰 등록용 기기 ID 준비 실패: \(error.localizedDescription)", + category: .general + ) + return + } + + let registrationKey = RegistrationKey(token: fcmToken, deviceId: deviceId) + guard !inFlightRegistrations.contains(registrationKey) else { return } + inFlightRegistrations.insert(registrationKey) + defer { inFlightRegistrations.remove(registrationKey) } + + let deviceInfo = DeviceInfoProvider.current() + let registration = FCMRegistration( + token: fcmToken, + deviceId: deviceId, + deviceType: .ios, + deviceModel: deviceInfo.deviceModel, + osVersion: deviceInfo.osVersion, + appVersion: deviceInfo.appVersion + ) + + do { + try await registerFCMToken.execute(registration: registration) + Logger.shared.info("FCM 토큰 서버 등록 완료", category: .general) + } catch { + Logger.shared.error( + "FCM 토큰 서버 등록 실패: \(error.localizedDescription)", + category: .general + ) + } + } +} diff --git a/Projects/App/Sources/Push/DeviceInfoProvider.swift b/Projects/App/Sources/Push/DeviceInfoProvider.swift new file mode 100644 index 00000000..e253c709 --- /dev/null +++ b/Projects/App/Sources/Push/DeviceInfoProvider.swift @@ -0,0 +1,29 @@ +import UIKit +import Utils + +struct DeviceInfo { + let deviceModel: String + let osVersion: String + let appVersion: String +} + +enum DeviceInfoProvider { + static func current() -> DeviceInfo { + DeviceInfo( + deviceModel: deviceModel(), + osVersion: UIDevice.current.systemVersion, + appVersion: AppInfo.version + ) + } + + private static func deviceModel() -> String { + var systemInfo = utsname() + uname(&systemInfo) + + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + } +} diff --git a/Projects/App/Sources/Push/PushTokenSyncer.swift b/Projects/App/Sources/Push/PushTokenSyncer.swift deleted file mode 100644 index 09052ead..00000000 --- a/Projects/App/Sources/Push/PushTokenSyncer.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Logger - -protocol PushTokenSyncing: AnyObject { - func sync(fcmToken: String) async -} - -/// 서버 API 스펙이 연결되기 전까지 토큰 수신 지점만 고정한다. -final class DeferredPushTokenSyncer: PushTokenSyncing { - private var lastObservedToken: String? - - func sync(fcmToken: String) async { - guard lastObservedToken != fcmToken else { return } - - lastObservedToken = fcmToken - Logger.shared.info( - "FCM 토큰 수신됨. 서버 등록 API 연결 대기", - category: .general - ) - } -} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index f67973f8..4beb0029 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -14,10 +14,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let windowScene = (scene as? UIWindowScene) else { return } - let config = AppConfiguration() + AppContainer.bootstrap(config: AppConfiguration()) pushNotificationRegistrar.requestPermissionIfNeeded() - let factory = AppFactory(config: config) + let factory = AppContainer.shared.factory let nav = CommonNavigationController() @@ -34,6 +34,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { pushNotificationRegistrar.syncRegistrationIfAuthorized() + + Task { + await AppContainer.shared.pushTokenSyncer.syncCurrentTokenIfAuthenticated() + } } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { From 12664ca1a3dc1db60617be3c49871df0c5d0cad3 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 7 Jun 2026 20:11:38 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20FCM=20=ED=86=A0=ED=81=B0=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20LogoutUseCase=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Auth/UseCase/LogoutUseCase.swift | 8 ++- .../Util/Factory/DomainFactory+Auth.swift | 5 +- .../Auth/UseCase/LogoutUseCaseTests.swift | 50 ++++++++++++++----- .../Mocks/MockDiscoveryRepository.swift | 8 +-- ...commendationsForCategoryUseCaseTests.swift | 4 +- ...RecommendationsForUploadUseCaseTests.swift | 4 +- 6 files changed, 57 insertions(+), 22 deletions(-) diff --git a/Projects/Domain/Sources/Auth/UseCase/LogoutUseCase.swift b/Projects/Domain/Sources/Auth/UseCase/LogoutUseCase.swift index 242c3831..e818fb9b 100644 --- a/Projects/Domain/Sources/Auth/UseCase/LogoutUseCase.swift +++ b/Projects/Domain/Sources/Auth/UseCase/LogoutUseCase.swift @@ -4,12 +4,18 @@ public protocol LogoutUseCase { public final class DefaultLogoutUseCase: LogoutUseCase { private let authRepo: AuthRepository + private let deactivateFCMToken: DeactivateFCMTokenUseCase - public init(authRepo: AuthRepository) { + public init( + authRepo: AuthRepository, + deactivateFCMToken: DeactivateFCMTokenUseCase + ) { self.authRepo = authRepo + self.deactivateFCMToken = deactivateFCMToken } public func execute() async throws { + try? await deactivateFCMToken.execute() try await authRepo.logout() } } diff --git a/Projects/Domain/Sources/Util/Factory/DomainFactory+Auth.swift b/Projects/Domain/Sources/Util/Factory/DomainFactory+Auth.swift index 213c850d..dd521dc9 100644 --- a/Projects/Domain/Sources/Util/Factory/DomainFactory+Auth.swift +++ b/Projects/Domain/Sources/Util/Factory/DomainFactory+Auth.swift @@ -27,7 +27,10 @@ public extension DefaultDomainFactory { func makeLogoutUseCase() -> LogoutUseCase { let authRepo = factory.makeAuthRepository() - return DefaultLogoutUseCase(authRepo: authRepo) + return DefaultLogoutUseCase( + authRepo: authRepo, + deactivateFCMToken: makeDeactivateFCMTokenUseCase() + ) } func makeCheckFullAuthenticationUseCase() -> CheckFullAuthenticationUseCase { diff --git a/Projects/Domain/Tests/Auth/UseCase/LogoutUseCaseTests.swift b/Projects/Domain/Tests/Auth/UseCase/LogoutUseCaseTests.swift index 6d18aa0a..bfd93655 100644 --- a/Projects/Domain/Tests/Auth/UseCase/LogoutUseCaseTests.swift +++ b/Projects/Domain/Tests/Auth/UseCase/LogoutUseCaseTests.swift @@ -2,31 +2,57 @@ import XCTest @testable import Domain final class LogoutUseCaseTests: XCTestCase { - private func makeSUT() -> (MockAuthRepository, DefaultLogoutUseCase) { - let mockRepository = MockAuthRepository() - let sut = DefaultLogoutUseCase(authRepo: mockRepository) - return (mockRepository, sut) + private struct SUT { + let authRepository: MockAuthRepository + let fcmRepository: MockFCMRepository + let useCase: DefaultLogoutUseCase } - func test_정상호출_로그아웃실행() async throws { - let (mockRepository, sut) = makeSUT() + private func makeSUT() -> SUT { + let authRepository = MockAuthRepository() + let fcmRepository = MockFCMRepository() + let useCase = DefaultLogoutUseCase( + authRepo: authRepository, + deactivateFCMToken: DefaultDeactivateFCMTokenUseCase(fcmRepository: fcmRepository) + ) + return SUT( + authRepository: authRepository, + fcmRepository: fcmRepository, + useCase: useCase + ) + } + + func test_정상호출_FCM비활성화후로그아웃() async throws { + let sut = makeSUT() + + try await sut.useCase.execute() + + XCTAssertEqual(sut.fcmRepository.deactivateCallCount, 1) + XCTAssertEqual(sut.authRepository.logoutCallCount, 1) + } + + func test_FCM비활성화실패_로그아웃은진행() async throws { + let sut = makeSUT() + sut.fcmRepository.deactivateError = MockFCMError.failed - try await sut.execute() + try await sut.useCase.execute() - XCTAssertEqual(mockRepository.logoutCallCount, 1) + XCTAssertEqual(sut.fcmRepository.deactivateCallCount, 1) + XCTAssertEqual(sut.authRepository.logoutCallCount, 1) } func test_로그아웃실패_에러전파() async { - let (mockRepository, sut) = makeSUT() - mockRepository.logoutError = MockAuthError.failed + let sut = makeSUT() + sut.authRepository.logoutError = MockAuthError.failed do { - try await sut.execute() + try await sut.useCase.execute() XCTFail("에러가 발생해야 합니다") } catch { XCTAssertEqual(error as? MockAuthError, .failed) } - XCTAssertEqual(mockRepository.logoutCallCount, 1) + XCTAssertEqual(sut.fcmRepository.deactivateCallCount, 1) + XCTAssertEqual(sut.authRepository.logoutCallCount, 1) } } diff --git a/Projects/Domain/Tests/Discovery/Mocks/MockDiscoveryRepository.swift b/Projects/Domain/Tests/Discovery/Mocks/MockDiscoveryRepository.swift index 4d1df286..f5fe8f40 100644 --- a/Projects/Domain/Tests/Discovery/Mocks/MockDiscoveryRepository.swift +++ b/Projects/Domain/Tests/Discovery/Mocks/MockDiscoveryRepository.swift @@ -15,8 +15,8 @@ final class MockDiscoveryRepository: DiscoveryRepository { private(set) var loadCountrySouvenirsCallCount = 0 private(set) var loadTopSouvenirsByCategoryCallCount = 0 private(set) var lastCategory: SouvenirCategory? - private(set) var loadAIRecommendationsForCategoryCallCount = 0 - private(set) var loadAIRecommendationsForUploadCallCount = 0 + private(set) var aiRecCategoryCallCount = 0 + private(set) var aiRecUploadCallCount = 0 func loadCountrySouvenirs() async throws -> [CountryTopSouvenir] { loadCountrySouvenirsCallCount += 1 @@ -40,7 +40,7 @@ final class MockDiscoveryRepository: DiscoveryRepository { } func loadAIRecommendationsForCategory() async throws -> [CatalogSouvenir] { - loadAIRecommendationsForCategoryCallCount += 1 + aiRecCategoryCallCount += 1 if let loadAIRecommendationsForCategoryError { throw loadAIRecommendationsForCategoryError @@ -50,7 +50,7 @@ final class MockDiscoveryRepository: DiscoveryRepository { } func loadAIRecommendationsForUpload() async throws -> [CatalogSouvenir] { - loadAIRecommendationsForUploadCallCount += 1 + aiRecUploadCallCount += 1 if let loadAIRecommendationsForUploadError { throw loadAIRecommendationsForUploadError diff --git a/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForCategoryUseCaseTests.swift b/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForCategoryUseCaseTests.swift index 852f7680..f0ff1b08 100644 --- a/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForCategoryUseCaseTests.swift +++ b/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForCategoryUseCaseTests.swift @@ -26,7 +26,7 @@ final class LoadAIRecommendationsForCategoryUseCaseTests: XCTestCase { let result = try await sut.execute() XCTAssertEqual(result, expected) - XCTAssertEqual(mockRepository.loadAIRecommendationsForCategoryCallCount, 1) + XCTAssertEqual(mockRepository.aiRecCategoryCallCount, 1) } func test_조회실패_에러전파() async { @@ -40,6 +40,6 @@ final class LoadAIRecommendationsForCategoryUseCaseTests: XCTestCase { XCTAssertEqual(error as? MockDiscoveryError, .failed) } - XCTAssertEqual(mockRepository.loadAIRecommendationsForCategoryCallCount, 1) + XCTAssertEqual(mockRepository.aiRecCategoryCallCount, 1) } } diff --git a/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForUploadUseCaseTests.swift b/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForUploadUseCaseTests.swift index 65e80d83..aece9f10 100644 --- a/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForUploadUseCaseTests.swift +++ b/Projects/Domain/Tests/Discovery/UseCase/LoadAIRecommendationsForUploadUseCaseTests.swift @@ -26,7 +26,7 @@ final class LoadAIRecommendationsForUploadUseCaseTests: XCTestCase { let result = try await sut.execute() XCTAssertEqual(result, expected) - XCTAssertEqual(mockRepository.loadAIRecommendationsForUploadCallCount, 1) + XCTAssertEqual(mockRepository.aiRecUploadCallCount, 1) } func test_조회실패_에러전파() async { @@ -40,6 +40,6 @@ final class LoadAIRecommendationsForUploadUseCaseTests: XCTestCase { XCTAssertEqual(error as? MockDiscoveryError, .failed) } - XCTAssertEqual(mockRepository.loadAIRecommendationsForUploadCallCount, 1) + XCTAssertEqual(mockRepository.aiRecUploadCallCount, 1) } } From 0e87720a4334a7d2fa17e9dda87f1038cae121e8 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Sun, 14 Jun 2026 16:38:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=EB=AA=A8=EB=93=88=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20xcodebuild=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/souzip-plan/SKILL.md | 3 +- .codex/skills/souzip-verify/SKILL.md | 5 +- docs/harness/scripts/verify.sh | 74 +++++++++++++++++++- docs/harness/templates/plan/plan-template.md | 3 +- docs/harness/verification.md | 22 +++++- docs/harness/workflow/operating-sequence.md | 2 +- 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/.codex/skills/souzip-plan/SKILL.md b/.codex/skills/souzip-plan/SKILL.md index 5efca1cf..c6f75865 100644 --- a/.codex/skills/souzip-plan/SKILL.md +++ b/.codex/skills/souzip-plan/SKILL.md @@ -22,7 +22,8 @@ Use this skill to create a Plan that can be implemented and verified in one sess 4. List implementation tasks. 5. Add verification commands: - default: `docs/harness/scripts/verify.sh plan` - - related tests with `VERIFY_TEST_TARGETS` or `VERIFY_TEST_SCHEME` when applicable + - related module tests with `VERIFY_TEST_SCHEME` when applicable, for example `VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan` + - use `VERIFY_TEST_TARGETS` only with `VERIFY_TEST_SCHEME` to narrow `xcodebuild -only-testing` 6. Add stop conditions for structural changes. 7. Wait for user approval and the explicit "구현해" instruction before code edits. diff --git a/.codex/skills/souzip-verify/SKILL.md b/.codex/skills/souzip-verify/SKILL.md index f6f6cc15..c85feaa7 100644 --- a/.codex/skills/souzip-verify/SKILL.md +++ b/.codex/skills/souzip-verify/SKILL.md @@ -20,7 +20,10 @@ Use this skill to verify completed work and classify failures. 2. Run the matching verification command when appropriate: - Plan: `docs/harness/scripts/verify.sh plan` - Story: `docs/harness/scripts/verify.sh story` -3. Include related tests with `VERIFY_TEST_TARGETS` or `VERIFY_TEST_SCHEME` when applicable. +3. Include related tests with `VERIFY_TEST_SCHEME` when applicable. + - For module tests, prefer `VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan`. + - The script routes module schemes to `xcodebuild test` directly because `tuist test` can miss module tests via selective testing or workspace scheme resolution. + - Use `VERIFY_TEST_TARGETS` only as an optional `xcodebuild -only-testing` filter together with `VERIFY_TEST_SCHEME`. 4. Record passed checks, failed checks, skipped checks, and skip reasons. 5. Classify failures: - Plan scope: fix and rerun the same verification. diff --git a/docs/harness/scripts/verify.sh b/docs/harness/scripts/verify.sh index ab918855..0b29bc84 100755 --- a/docs/harness/scripts/verify.sh +++ b/docs/harness/scripts/verify.sh @@ -2,7 +2,7 @@ # Souzip harness verification. # Usage: # docs/harness/scripts/verify.sh plan -# VERIFY_TEST_TARGETS="DomainTests" docs/harness/scripts/verify.sh plan +# VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan # docs/harness/scripts/verify.sh story set -euo pipefail @@ -14,6 +14,7 @@ cd "$ROOT" BUILD_SCHEME="${VERIFY_BUILD_SCHEME:-수집-Debug}" TEST_SCHEME="${VERIFY_TEST_SCHEME:-}" TEST_TARGETS="${VERIFY_TEST_TARGETS:-}" +TEST_DESTINATION="${VERIFY_TEST_DESTINATION:-}" run_tool() { local tool="$1" @@ -31,7 +32,58 @@ run_tool() { fi } -run_test() { +module_project_for_scheme() { + local scheme="$1" + local candidates=( + "Projects/$scheme/$scheme.xcodeproj" + "Projects/Core/$scheme/$scheme.xcodeproj" + "Projects/Shared/$scheme/$scheme.xcodeproj" + ) + + local candidate + for candidate in "${candidates[@]}"; do + if [ -d "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + + return 1 +} + +default_test_destination() { + local device_id + device_id="$(xcrun simctl list devices available 2>/dev/null | sed -nE 's/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -n 1)" + + if [ -n "$device_id" ]; then + echo "platform=iOS Simulator,id=$device_id" + else + echo "generic/platform=iOS Simulator" + fi +} + +run_xcodebuild_test() { + local project="$1" + local scheme="$2" + local destination="${TEST_DESTINATION:-$(default_test_destination)}" + local args=( + test + -project "$project" + -scheme "$scheme" + -destination "$destination" + ) + + if [ -n "$TEST_TARGETS" ]; then + local target + for target in ${TEST_TARGETS//,/ }; do + args+=("-only-testing:$target") + done + fi + + xcodebuild "${args[@]}" +} + +run_tuist_test() { if [ -n "$TEST_TARGETS" ]; then if [ -n "$TEST_SCHEME" ]; then run_tool tuist test "$TEST_SCHEME" --test-targets "$TEST_TARGETS" --skip-ui-tests --no-upload @@ -40,8 +92,24 @@ run_test() { fi elif [ -n "$TEST_SCHEME" ]; then run_tool tuist test "$TEST_SCHEME" --skip-ui-tests --no-upload - elif [ "$MODE" = "story" ] || [ "$MODE" = "pr" ]; then + else run_tool tuist test --skip-ui-tests --no-upload + fi +} + +run_test() { + if [ -n "$TEST_SCHEME" ]; then + local project + if project="$(module_project_for_scheme "$TEST_SCHEME")"; then + echo "Using xcodebuild test for module scheme '$TEST_SCHEME'" + echo "Project: $project" + echo "Destination: ${TEST_DESTINATION:-$(default_test_destination)}" + run_xcodebuild_test "$project" "$TEST_SCHEME" + else + run_tuist_test + fi + elif [ "$MODE" = "story" ] || [ "$MODE" = "pr" ]; then + run_tuist_test else echo "SKIP: related tests not specified. Set VERIFY_TEST_TARGETS or VERIFY_TEST_SCHEME, or record the skip reason in the Plan." fi diff --git a/docs/harness/templates/plan/plan-template.md b/docs/harness/templates/plan/plan-template.md index 885264e2..8b9914cf 100644 --- a/docs/harness/templates/plan/plan-template.md +++ b/docs/harness/templates/plan/plan-template.md @@ -25,7 +25,8 @@ docs/harness/scripts/verify.sh plan 관련 테스트: ```bash -# 예: VERIFY_TEST_TARGETS="DomainTests" docs/harness/scripts/verify.sh plan +# 예: VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan +# 예: VERIFY_TEST_SCHEME="Domain" VERIFY_TEST_TARGETS="DomainTests/RegisterFCMTokenUseCaseTests" docs/harness/scripts/verify.sh plan ``` ## 완료 기준 diff --git a/docs/harness/verification.md b/docs/harness/verification.md index 662de091..9f4513cc 100644 --- a/docs/harness/verification.md +++ b/docs/harness/verification.md @@ -26,8 +26,28 @@ docs/harness/scripts/verify.sh plan 관련 테스트는 Plan에 맞게 지정합니다. ```bash -VERIFY_TEST_TARGETS="DomainTests" docs/harness/scripts/verify.sh plan VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan +VERIFY_TEST_SCHEME="Domain" VERIFY_TEST_TARGETS="DomainTests/RegisterFCMTokenUseCaseTests" docs/harness/scripts/verify.sh plan +``` + +`VERIFY_TEST_SCHEME`이 `Domain`, `Data`, `Presentation`, `Storage`처럼 모듈 프로젝트와 같은 이름이면 +스크립트는 `tuist test` 대신 아래 형태의 `xcodebuild test`를 직접 실행합니다. +`tuist test`가 선택 테스트나 워크스페이스 스킴 해석 때문에 테스트를 못 잡는 경우가 있어서입니다. + +```bash +xcodebuild test \ + -project Projects/Domain/Domain.xcodeproj \ + -scheme Domain \ + -destination "platform=iOS Simulator,id={available-simulator-id}" +``` + +기본 시뮬레이터 대상은 `xcrun simctl list devices available`에서 찾은 첫 번째 사용 가능 기기입니다. +특정 기기를 고정해야 하면 `VERIFY_TEST_DESTINATION`을 지정합니다. + +```bash +VERIFY_TEST_SCHEME="Domain" \ +VERIFY_TEST_DESTINATION="platform=iOS Simulator,name=iPhone 13 mini" \ +docs/harness/scripts/verify.sh plan ``` 관련 테스트가 없으면 Plan 문서에 생략 이유를 적습니다. diff --git a/docs/harness/workflow/operating-sequence.md b/docs/harness/workflow/operating-sequence.md index 14b57b86..fd37fd9c 100644 --- a/docs/harness/workflow/operating-sequence.md +++ b/docs/harness/workflow/operating-sequence.md @@ -105,8 +105,8 @@ docs/harness/scripts/verify.sh plan 관련 테스트가 있으면 Plan에 맞게 지정합니다. ```bash -VERIFY_TEST_TARGETS="DomainTests" docs/harness/scripts/verify.sh plan VERIFY_TEST_SCHEME="Domain" docs/harness/scripts/verify.sh plan +VERIFY_TEST_SCHEME="Domain" VERIFY_TEST_TARGETS="DomainTests/RegisterFCMTokenUseCaseTests" docs/harness/scripts/verify.sh plan ``` 검증 실패가 Plan 범위 안이면 AI가 고치고 같은 검증을 다시 실행합니다.