diff --git a/Application/App/Sources/App/DevLogApp.swift b/Application/App/Sources/App/DevLogApp.swift index 932f14f8..bb7215c3 100644 --- a/Application/App/Sources/App/DevLogApp.swift +++ b/Application/App/Sources/App/DevLogApp.swift @@ -31,6 +31,7 @@ struct DevLogApp: App { networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), + checkAppUpdateUseCase: container.resolve(CheckAppUpdateUseCase.self), widgetURLTab: { MainTab(widgetURL: $0) }, windowEvent: windowEvent, pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(), diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index 3b8f5b05..8e50628c 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -2,6 +2,8 @@ + APP_STORE_URL + $(APP_STORE_URL) TESTFLIGHT_URL $(TESTFLIGHT_URL) CFBundleDevelopmentRegion diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index 4b2792d2..c8b9dc92 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -1493,6 +1493,57 @@ } } }, + "root_app_update_action" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트" + } + } + } + }, + "root_app_update_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update to the latest version to continue using DevLog." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "DevLog를 계속 사용하려면 최신 버전으로 업데이트해주세요." + } + } + } + }, + "root_app_update_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Required" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 필요" + } + } + } + }, "root_network_disconnected_message" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Data/Sources/DataAssembler.swift b/Application/Data/Sources/DataAssembler.swift index 98f4f5b2..f41ba3fa 100644 --- a/Application/Data/Sources/DataAssembler.swift +++ b/Application/Data/Sources/DataAssembler.swift @@ -72,6 +72,12 @@ public final class DataAssembler: Assembler { ) } + container.register(AppVersionRepository.self) { + AppVersionRepositoryImpl( + service: container.resolve(AppVersionConfigurationService.self) + ) + } + container.register(AuthDataRepository.self) { AuthDataRepositoryImpl( authService: container.resolve(AuthService.self), diff --git a/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift b/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift new file mode 100644 index 00000000..addbc0d6 --- /dev/null +++ b/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift @@ -0,0 +1,10 @@ +// +// AppVersionConfigurationService.swift +// Data +// +// Created by opfic on 7/22/26. +// + +public protocol AppVersionConfigurationService { + func fetchRequiredVersion() async throws -> String +} diff --git a/Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift b/Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift new file mode 100644 index 00000000..6648357a --- /dev/null +++ b/Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift @@ -0,0 +1,20 @@ +// +// AppVersionRepositoryImpl.swift +// Data +// +// Created by opfic on 7/22/26. +// + +import Domain + +final class AppVersionRepositoryImpl: AppVersionRepository { + private let service: AppVersionConfigurationService + + init(service: AppVersionConfigurationService) { + self.service = service + } + + func fetchRequiredVersion() async throws -> String { + try await service.fetchRequiredVersion() + } +} diff --git a/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift b/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift new file mode 100644 index 00000000..09a11d2e --- /dev/null +++ b/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift @@ -0,0 +1,54 @@ +// +// AppVersionRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 7/22/26. +// + +import Testing +@testable import Data + +struct AppVersionRepositoryImplTests { + @Test("필수 버전 조회는 구성 서비스의 값을 반환한다") + func 필수_버전_조회는_구성_서비스의_값을_반환한다() async throws { + let service = AppVersionConfigurationServiceSpy(result: .success("1.5")) + let repository = AppVersionRepositoryImpl(service: service) + + #expect(try await repository.fetchRequiredVersion() == "1.5") + #expect(await service.fetchCallCount() == 1) + } + + @Test("필수 버전 조회는 구성 서비스의 오류를 그대로 반환한다") + func 필수_버전_조회는_구성_서비스의_오류를_그대로_반환한다() async { + let service = AppVersionConfigurationServiceSpy( + result: .failure(AppVersionConfigurationServiceTestError.fetchFailed) + ) + let repository = AppVersionRepositoryImpl(service: service) + + await #expect(throws: AppVersionConfigurationServiceTestError.fetchFailed) { + try await repository.fetchRequiredVersion() + } + } +} + +private actor AppVersionConfigurationServiceSpy: AppVersionConfigurationService { + private let result: Result + private var count = 0 + + init(result: Result) { + self.result = result + } + + func fetchRequiredVersion() async throws -> String { + count += 1 + return try result.get() + } + + func fetchCallCount() -> Int { + count + } +} + +private enum AppVersionConfigurationServiceTestError: Error { + case fetchFailed +} diff --git a/Application/Domain/Project.swift b/Application/Domain/Project.swift index 54316598..9c417667 100644 --- a/Application/Domain/Project.swift +++ b/Application/Domain/Project.swift @@ -11,5 +11,5 @@ let project = Project.devlogFramework( dependencies: [ .project(target: "Core", path: "../Core") ], - hasTests: false + hasTests: true ) diff --git a/Application/Domain/Sources/DomainAssembler.swift b/Application/Domain/Sources/DomainAssembler.swift index fbccd8df..bb5f654f 100644 --- a/Application/Domain/Sources/DomainAssembler.swift +++ b/Application/Domain/Sources/DomainAssembler.swift @@ -12,6 +12,7 @@ public final class DomainAssembler: Assembler { public func assemble(_ container: any DIContainer) { registerAnalyticsUseCases(container) + registerAppUpdateUseCases(container) registerAuthUseCases(container) registerConnectivityUseCases(container) registerAuthProviderUseCases(container) @@ -25,6 +26,12 @@ public final class DomainAssembler: Assembler { } private extension DomainAssembler { + func registerAppUpdateUseCases(_ container: any DIContainer) { + container.register(CheckAppUpdateUseCase.self) { + CheckAppUpdateUseCaseImpl(container.resolve(AppVersionRepository.self)) + } + } + func registerAnalyticsUseCases(_ container: any DIContainer) { container.register(TrackAnalyticsEventUseCase.self) { TrackAnalyticsEventUseCaseImpl(container.resolve(AnalyticsRepository.self)) diff --git a/Application/Domain/Sources/Entity/AppVersion.swift b/Application/Domain/Sources/Entity/AppVersion.swift new file mode 100644 index 00000000..3fbaf06c --- /dev/null +++ b/Application/Domain/Sources/Entity/AppVersion.swift @@ -0,0 +1,40 @@ +// +// AppVersion.swift +// Domain +// +// Created by opfic on 7/22/26. +// + +public struct AppVersion: Comparable { + private let components: [Int] + + public init(marketingVersion: String, buildNumber: String) throws { + try self.init("\(marketingVersion).\(buildNumber)") + } + + init(_ value: String) throws { + let rawComponents = value.split(separator: ".", omittingEmptySubsequences: false) + guard !rawComponents.isEmpty, + rawComponents.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { + throw DomainLayerError.invalidData(context: "appVersion") + } + + let components = rawComponents.compactMap { Int($0) } + guard components.count == rawComponents.count else { + throw DomainLayerError.invalidData(context: "appVersion") + } + self.components = components + } + + public static func < (lhs: AppVersion, rhs: AppVersion) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + + for index in 0.. String +} diff --git a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift new file mode 100644 index 00000000..fbabafbf --- /dev/null +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift @@ -0,0 +1,10 @@ +// +// CheckAppUpdateUseCase.swift +// Domain +// +// Created by opfic on 7/22/26. +// + +public protocol CheckAppUpdateUseCase { + func execute() async throws -> Bool +} diff --git a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift new file mode 100644 index 00000000..576946c7 --- /dev/null +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift @@ -0,0 +1,37 @@ +// +// CheckAppUpdateUseCaseImpl.swift +// Domain +// +// Created by opfic on 7/22/26. +// + +import Foundation + +public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase { + private let repository: AppVersionRepository + + init(_ repository: AppVersionRepository) { + self.repository = repository + } + + public func execute() async throws -> Bool { + let requiredVersionValue = try await repository.fetchRequiredVersion() + let requiredVersion = try AppVersion(requiredVersionValue) + let currentVersion = try currentVersion() + return currentVersion < requiredVersion + } + + private func currentVersion() throws -> AppVersion { + guard let marketingVersion = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String, + let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else { + throw DomainLayerError.invalidData(context: "appVersion") + } + + return try AppVersion( + marketingVersion: marketingVersion, + buildNumber: buildNumber + ) + } +} diff --git a/Application/Domain/Tests/Entity/AppVersionTests.swift b/Application/Domain/Tests/Entity/AppVersionTests.swift new file mode 100644 index 00000000..2ff0a759 --- /dev/null +++ b/Application/Domain/Tests/Entity/AppVersionTests.swift @@ -0,0 +1,25 @@ +// +// AppVersionTests.swift +// DomainTests +// +// Created by opfic on 7/22/26. +// + +import Testing +@testable import Domain + +struct AppVersionTests { + @Test("마케팅 버전 형식이 잘못되면 invalidData 오류를 반환한다") + func 마케팅_버전_형식이_잘못되면_invalidData_오류를_반환한다() { + #expect(throws: DomainLayerError.self) { + try AppVersion(marketingVersion: "1..5", buildNumber: "127") + } + } + + @Test("빌드 번호 형식이 잘못되면 invalidData 오류를 반환한다") + func 빌드_번호_형식이_잘못되면_invalidData_오류를_반환한다() { + #expect(throws: DomainLayerError.self) { + try AppVersion(marketingVersion: "1.5", buildNumber: "127a") + } + } +} diff --git a/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift new file mode 100644 index 00000000..5e686ec8 --- /dev/null +++ b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift @@ -0,0 +1,83 @@ +// +// CheckAppUpdateUseCaseImplTests.swift +// DomainTests +// +// Created by opfic on 7/22/26. +// + +import Foundation +import Testing +@testable import Domain + +struct CheckAppUpdateUseCaseImplTests { + @Test("Bundle의 현재 버전보다 필수 버전이 높으면 업데이트가 필요하다") + func Bundle의_현재_버전보다_필수_버전이_높으면_업데이트가_필요하다() async throws { + let requiredVersion = "\(try currentVersionValue()).1" + let repository = AppVersionRepositorySpy(result: .success(requiredVersion)) + let useCase = CheckAppUpdateUseCaseImpl(repository) + + #expect(try await useCase.execute()) + #expect(await repository.fetchCallCount() == 1) + } + + @Test("Bundle의 현재 버전과 필수 버전이 같으면 업데이트가 필요하지 않다") + func Bundle의_현재_버전과_필수_버전이_같으면_업데이트가_필요하지_않다() async throws { + let repository = AppVersionRepositorySpy(result: .success(try currentVersionValue())) + let useCase = CheckAppUpdateUseCaseImpl(repository) + + #expect(try await !useCase.execute()) + #expect(await repository.fetchCallCount() == 1) + } + + @Test("필수 버전 형식이 잘못되면 invalidData 오류를 반환한다") + func 필수_버전_형식이_잘못되면_invalidData_오류를_반환한다() async throws { + let repository = AppVersionRepositorySpy(result: .success("latest")) + let useCase = CheckAppUpdateUseCaseImpl(repository) + await #expect(throws: DomainLayerError.self) { + try await useCase.execute() + } + } + + @Test("필수 버전 조회 오류를 그대로 반환한다") + func 필수_버전_조회_오류를_그대로_반환한다() async throws { + let repository = AppVersionRepositorySpy( + result: .failure(AppVersionRepositoryTestError.fetchFailed) + ) + let useCase = CheckAppUpdateUseCaseImpl(repository) + await #expect(throws: AppVersionRepositoryTestError.fetchFailed) { + try await useCase.execute() + } + } +} + +private func currentVersionValue() throws -> String { + let marketingVersion = try #require( + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ) + let buildNumber = try #require( + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ) + return "\(marketingVersion).\(buildNumber)" +} + +private actor AppVersionRepositorySpy: AppVersionRepository { + private let result: Result + private var count = 0 + + init(result: Result) { + self.result = result + } + + func fetchRequiredVersion() async throws -> String { + count += 1 + return try result.get() + } + + func fetchCallCount() -> Int { + count + } +} + +private enum AppVersionRepositoryTestError: Error { + case fetchFailed +} diff --git a/Application/Infra/Sources/InfraAssembler.swift b/Application/Infra/Sources/InfraAssembler.swift index ef485779..1ed91f78 100644 --- a/Application/Infra/Sources/InfraAssembler.swift +++ b/Application/Infra/Sources/InfraAssembler.swift @@ -16,6 +16,10 @@ public final class InfraAssembler: Assembler { FirebaseAppServiceImpl() } + container.register(AppVersionConfigurationService.self) { + RemoteConfigAppVersionServiceImpl() + } + container.register(AnalyticsService.self) { FirebaseAnalyticsServiceImpl() } diff --git a/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift b/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift new file mode 100644 index 00000000..ec0653e4 --- /dev/null +++ b/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift @@ -0,0 +1,45 @@ +// +// RemoteConfigAppVersionServiceImpl.swift +// Infra +// +// Created by opfic on 7/22/26. +// + +import Data +import FirebaseRemoteConfig +import Foundation + +enum AppVersionConfigurationError: Error { + case missingRequiredVersion +} + +final class RemoteConfigAppVersionServiceImpl: AppVersionConfigurationService { + private enum Key { + static let requiredVersion = "ios_required_version" + } + + private let remoteConfig = RemoteConfig.remoteConfig() + + init() { + remoteConfig.setDefaults([ + Key.requiredVersion: "" as NSString + ]) + } + + func fetchRequiredVersion() async throws -> String { + _ = try? await remoteConfig.fetchAndActivate() + + guard let version = normalizedRequiredVersion() else { + throw AppVersionConfigurationError.missingRequiredVersion + } + return version + } + + private func normalizedRequiredVersion() -> String? { + let version = remoteConfig + .configValue(forKey: Key.requiredVersion) + .stringValue + .trimmingCharacters(in: .whitespacesAndNewlines) + return version.isEmpty ? nil : version + } +} diff --git a/Application/Presentation/Entry/Sources/Root/RootFeature.swift b/Application/Presentation/Entry/Sources/Root/RootFeature.swift index 692b4ce2..0e090251 100644 --- a/Application/Presentation/Entry/Sources/Root/RootFeature.swift +++ b/Application/Presentation/Entry/Sources/Root/RootFeature.swift @@ -21,8 +21,10 @@ struct RootFeature { @ObservableState struct State: Equatable { - @Presents var alert: AlertState? + @Presents var alert: AlertState? @Presents var sheet: SheetState? + var hasCheckedAppUpdate = false + var isAppUpdateRequired = false var isNetworkConnected = true var signIn: Bool? var theme: SystemTheme = .automatic @@ -39,7 +41,8 @@ struct RootFeature { } enum Action: BindableAction, Equatable { - case alert(PresentationAction) + case alert(PresentationAction) + case appUpdateCheckCompleted(Bool) case binding(BindingAction) case sheet(PresentationAction) case onAppear @@ -49,6 +52,10 @@ struct RootFeature { case setTheme(SystemTheme) case didLogined(Bool) + enum Alert: Equatable { + case tapUpdateButton + } + enum Sheet: Equatable { case tapCloseButton } @@ -58,14 +65,23 @@ struct RootFeature { @Dependency(\.rootNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.rootSystemThemeUseCase) var systemThemeUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.checkAppUpdateUseCase) var checkAppUpdateUseCase + @Dependency(\.appStoreURL) var appStoreURL + @Dependency(\.openURL) var openURL @Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount var body: some ReducerOf { BindingReducer() Reduce { state, action in switch action { + case .alert(.presented(.tapUpdateButton)): + return openAppStoreEffect() case .alert: break + case .appUpdateCheckCompleted(let isRequired): + guard isRequired else { break } + state.isAppUpdateRequired = true + state.alert = Self.appUpdateAlertState() case .binding: break case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): @@ -75,6 +91,11 @@ struct RootFeature { case .onAppear: var effect = clearApplicationBadgeCountEffect() + if !state.hasCheckedAppUpdate { + state.hasCheckedAppUpdate = true + effect = .merge(effect, checkAppUpdateEffect()) + } + if !state.isObservingNetworkConnectivity { state.isObservingNetworkConnectivity = true effect = .merge(effect, observeNetworkConnectivityEffect()) @@ -99,8 +120,8 @@ struct RootFeature { case .networkStatusChanged(let isConnected): let wasConnected = state.isNetworkConnected state.isNetworkConnected = isConnected - if wasConnected && !isConnected { - state.alert = Self.alertState() + if wasConnected && !isConnected && !state.isAppUpdateRequired { + state.alert = Self.networkDisconnectedAlertState() } case .setTheme(let theme): state.theme = theme @@ -135,6 +156,16 @@ private struct RootSheetFeature: Reducer { } extension DependencyValues { + var appStoreURL: URL? { + get { self[AppStoreURLKey.self] } + set { self[AppStoreURLKey.self] = newValue } + } + + var checkAppUpdateUseCase: CheckAppUpdateUseCase { + get { self[CheckAppUpdateUseCaseKey.self] } + set { self[CheckAppUpdateUseCaseKey.self] = newValue } + } + var observeAuthSessionUseCase: ObserveAuthSessionUseCase { get { self[ObserveAuthSessionUseCaseKey.self] } set { self[ObserveAuthSessionUseCaseKey.self] = newValue } @@ -151,6 +182,31 @@ extension DependencyValues { } } +private enum AppStoreURLKey: DependencyKey { + static let liveValue = configuredAppStoreURL() + static let testValue: URL? = nil +} + +private func configuredAppStoreURL() -> URL? { + guard let rawValue = Bundle.main.object(forInfoDictionaryKey: "APP_STORE_URL") as? String else { + return nil + } + + let urlString = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !urlString.isEmpty, !urlString.hasPrefix("$(") else { return nil } + return URL(string: urlString) +} + +private enum CheckAppUpdateUseCaseKey: DependencyKey { + static var liveValue: CheckAppUpdateUseCase { + preconditionFailure("CheckAppUpdateUseCase must be provided.") + } + + static var testValue: CheckAppUpdateUseCase { + liveValue + } +} + private enum ObserveAuthSessionUseCaseKey: DependencyKey { static var liveValue: ObserveAuthSessionUseCase { preconditionFailure("ObserveAuthSessionUseCase must be provided.") @@ -182,6 +238,20 @@ private enum RootSystemThemeUseCaseKey: DependencyKey { } private extension RootFeature { + func checkAppUpdateEffect() -> Effect { + .run { [checkAppUpdateUseCase] send in + let isRequired = (try? await checkAppUpdateUseCase.execute()) ?? false + await send(.appUpdateCheckCompleted(isRequired)) + } + } + + func openAppStoreEffect() -> Effect { + .run { [appStoreURL, openURL] _ in + guard let appStoreURL else { return } + await openURL(appStoreURL) + } + } + func clearApplicationBadgeCountEffect() -> Effect { .run { [setApplicationBadgeCount] _ in try? await setApplicationBadgeCount(0) @@ -220,7 +290,19 @@ private extension RootFeature { } } - static func alertState() -> AlertState { + static func appUpdateAlertState() -> AlertState { + AlertState { + TextState(String(localized: "root_app_update_title")) + } actions: { + ButtonState(action: .tapUpdateButton) { + TextState(String(localized: "root_app_update_action")) + } + } message: { + TextState(String(localized: "root_app_update_message")) + } + } + + static func networkDisconnectedAlertState() -> AlertState { AlertState { TextState(String(localized: "root_network_disconnected_title")) } actions: { diff --git a/Application/Presentation/Entry/Sources/Root/RootView.swift b/Application/Presentation/Entry/Sources/Root/RootView.swift index 4140fe39..8c3efbb5 100644 --- a/Application/Presentation/Entry/Sources/Root/RootView.swift +++ b/Application/Presentation/Entry/Sources/Root/RootView.swift @@ -24,6 +24,7 @@ public struct RootView: View { networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, + checkAppUpdateUseCase: CheckAppUpdateUseCase, widgetURLTab: @escaping (URL) -> MainTab?, windowEvent: TodoEditorWindowEvent, pushNotificationTodoIdPublisher: AnyPublisher, @@ -36,6 +37,7 @@ public struct RootView: View { $0.rootNetworkConnectivityUseCase = networkConnectivityUseCase $0.rootSystemThemeUseCase = systemThemeUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.checkAppUpdateUseCase = checkAppUpdateUseCase }) self.widgetURLTab = widgetURLTab self.windowEvent = windowEvent diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift index 14c15d6b..9cf7efec 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift @@ -27,6 +27,7 @@ protocol RootStateDriving { func dismissSheet() async func selectMainTab(_ tab: MainTab) async func openWidgetRoute(_ tab: MainTab) async + func tapUpdateButton() async } struct RootStateSnapshot: Equatable { @@ -63,6 +64,9 @@ struct RootStoreTestAdapter: RootStateDriving { currentValue: .automatic ), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = RootTrackAnalyticsEventUseCaseSpy(), + checkAppUpdateUseCase: CheckAppUpdateUseCase = RootCheckAppUpdateUseCaseSpy(), + appStoreURL: URL? = URL(string: "https://apps.apple.com/us/app/devlog/id6760288611"), + openURLSpy: RootOpenURLSpy = RootOpenURLSpy(), badgeCountSpy: RootApplicationBadgeCountSpy = RootApplicationBadgeCountSpy() ) { store = TestStore(initialState: RootFeature.State()) { @@ -72,6 +76,12 @@ struct RootStoreTestAdapter: RootStateDriving { $0.rootNetworkConnectivityUseCase = networkConnectivityUseCase $0.rootSystemThemeUseCase = systemThemeUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.checkAppUpdateUseCase = checkAppUpdateUseCase + $0.appStoreURL = appStoreURL + $0.openURL = .init { url in + await openURLSpy.open(url) + return true + } $0.setApplicationBadgeCount = { count in try await badgeCountSpy.setBadgeCount(count) } @@ -120,6 +130,10 @@ struct RootStoreTestAdapter: RootStateDriving { await store.send(.openWidgetRoute(tab)) } + func tapUpdateButton() async { + await store.send(.alert(.presented(.tapUpdateButton))) + } + private func drainReceivedActions() async { for _ in 0..<8 { await store.skipReceivedActions(strict: false) @@ -299,6 +313,40 @@ final class RootTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { } } +actor RootCheckAppUpdateUseCaseSpy: CheckAppUpdateUseCase { + private let result: Result + private var count = 0 + + init(result: Result = .success(false)) { + self.result = result + } + + func execute() async throws -> Bool { + count += 1 + return try result.get() + } + + func executeCallCount() -> Int { + count + } +} + +actor RootOpenURLSpy { + private var urls = [URL]() + + func open(_ url: URL) { + urls.append(url) + } + + func openCallCount() -> Int { + urls.count + } + + func openedURLs() -> [URL] { + urls + } +} + final class RootApplicationBadgeCountSpy: @unchecked Sendable { private let lock = NSLock() private var protectedCounts = [Int]() diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift index d7a841c4..8ed7a86a 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift @@ -6,6 +6,7 @@ // import Core +import Foundation import Testing @MainActor @@ -104,6 +105,53 @@ struct RootFeatureTests { #expect(badgeSpy.counts == [0, 0]) } + @Test("RootFeature onAppear는 로그인 상태와 무관하게 업데이트를 한 번만 확인한다") + func RootFeature_onAppear는_로그인_상태와_무관하게_업데이트를_한_번만_확인한다() async { + let checkSpy = RootCheckAppUpdateUseCaseSpy() + let adapter = RootStoreTestAdapter( + sessionUseCase: ObserveAuthSessionUseCaseSpy(currentValue: false), + checkAppUpdateUseCase: checkSpy + ) + + await adapter.onAppear() + await adapter.onAppear() + + #expect(await checkSpy.executeCallCount() == 1) + } + + @Test("필수 업데이트 알림은 네트워크 연결 알림보다 우선한다") + func 필수_업데이트_알림은_네트워크_연결_알림보다_우선한다() async { + let adapter = RootStoreTestAdapter( + networkConnectivityUseCase: RootObserveNetworkConnectivityUseCaseSpy(currentValue: false), + checkAppUpdateUseCase: RootCheckAppUpdateUseCaseSpy(result: .success(true)) + ) + + await adapter.onAppear() + + #expect(adapter.snapshot.alertTitle == String(localized: "root_app_update_title")) + #expect(adapter.snapshot.alertMessage == String(localized: "root_app_update_message")) + } + + @Test("업데이트 버튼은 App Store 열기를 요청한다") + func 업데이트_버튼은_App_Store_열기를_요청한다() async throws { + let appStoreURL = try #require(URL(string: "https://apps.apple.com/us/app/devlog/id6760288611")) + let openSpy = RootOpenURLSpy() + let adapter = RootStoreTestAdapter( + checkAppUpdateUseCase: RootCheckAppUpdateUseCaseSpy(result: .success(true)), + appStoreURL: appStoreURL, + openURLSpy: openSpy + ) + + await adapter.onAppear() + await adapter.tapUpdateButton() + await waitUntil { + await openSpy.openCallCount() == 1 + } + + #expect(await openSpy.openCallCount() == 1) + #expect(await openSpy.openedURLs() == [appStoreURL]) + } + @Test("RootFeature는 TodoDetail sheet 표시와 해제를 store state로 관리한다") func RootFeature는_TodoDetail_sheet_표시와_해제를_store_state로_관리한다() async { let adapter = RootStoreTestAdapter() diff --git a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift index da6280ea..950f9757 100644 --- a/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Support/EntryTestSupport.swift @@ -11,12 +11,12 @@ import Domain func waitUntil( timeout: Duration = .seconds(1), pollInterval: Duration = .milliseconds(20), - _ condition: @escaping () -> Bool + _ condition: @escaping () async -> Bool ) async { let continuousClock = ContinuousClock() let deadline = continuousClock.now + timeout - while !condition() && continuousClock.now < deadline { + while !(await condition()) && continuousClock.now < deadline { try? await Task.sleep(for: pollInterval) } } diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index bde4bbf6..0da16a9c 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -36,6 +36,7 @@ public enum DevLogPackages { .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), + .package(product: "FirebaseRemoteConfig"), .package(product: "Nexa"), ]