From 4e89d8627bfe14e691f98673425a35ec17d70d5b Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 16:10:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Remote=20Config=20=EC=95=B1=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B5=AC=EC=84=B1=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppVersionConfigurationService.swift | 10 +++++ .../Infra/Sources/InfraAssembler.swift | 4 ++ .../RemoteConfigAppVersionServiceImpl.swift | 45 +++++++++++++++++++ .../Project+Packages.swift | 1 + 4 files changed, 60 insertions(+) create mode 100644 Application/Data/Sources/Protocol/AppVersionConfigurationService.swift create mode 100644 Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift 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/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/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"), ] From e11ee5855b9d01e15fa3b73837715190880f0c1f Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 17:18:01 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=95=B1=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=B9=84=EA=B5=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Data/Sources/DataAssembler.swift | 6 ++ .../Repository/AppVersionRepositoryImpl.swift | 20 +++++ .../AppVersionRepositoryImplTests.swift | 54 ++++++++++++ Application/Domain/Project.swift | 2 +- .../Domain/Sources/DomainAssembler.swift | 7 ++ .../Domain/Sources/Entity/AppVersion.swift | 40 +++++++++ .../Protocol/AppVersionRepository.swift | 10 +++ .../AppUpdate/CheckAppUpdateUseCase.swift | 10 +++ .../AppUpdate/CheckAppUpdateUseCaseImpl.swift | 20 +++++ .../Domain/Tests/Entity/AppVersionTests.swift | 25 ++++++ .../CheckAppUpdateUseCaseImplTests.swift | 85 +++++++++++++++++++ 11 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift create mode 100644 Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift create mode 100644 Application/Domain/Sources/Entity/AppVersion.swift create mode 100644 Application/Domain/Sources/Protocol/AppVersionRepository.swift create mode 100644 Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift create mode 100644 Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift create mode 100644 Application/Domain/Tests/Entity/AppVersionTests.swift create mode 100644 Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift 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/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..1e05fd68 --- /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(_ currentVersion: AppVersion) 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..60016df9 --- /dev/null +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift @@ -0,0 +1,20 @@ +// +// CheckAppUpdateUseCaseImpl.swift +// Domain +// +// Created by opfic on 7/22/26. +// + +public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase { + private let repository: AppVersionRepository + + init(_ repository: AppVersionRepository) { + self.repository = repository + } + + public func execute(_ currentVersion: AppVersion) async throws -> Bool { + let requiredVersionValue = try await repository.fetchRequiredVersion() + let requiredVersion = try AppVersion(requiredVersionValue) + return currentVersion < requiredVersion + } +} 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..bc03313f --- /dev/null +++ b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift @@ -0,0 +1,85 @@ +// +// CheckAppUpdateUseCaseImplTests.swift +// DomainTests +// +// Created by opfic on 7/22/26. +// + +import Testing +@testable import Domain + +struct CheckAppUpdateUseCaseImplTests { + @Test( + "현재 마케팅 버전과 빌드 번호를 필수 버전과 비교한다", + arguments: [ + ("1.4", "9", "1.5.1", true), + ("1.5", "127", "1.5.128", true), + ("1.5", "128", "1.5.128", false), + ("1.5", "129", "1.5.128", false), + ("1.9", "9", "1.10.1", true), + ("1.5", "1", "1.5.0", false) + ] + ) + func 현재_마케팅_버전과_빌드_번호를_필수_버전과_비교한다( + marketingVersion: String, + buildNumber: String, + requiredVersion: String, + expectedResult: Bool + ) async throws { + let repository = AppVersionRepositorySpy(result: .success(requiredVersion)) + let useCase = CheckAppUpdateUseCaseImpl(repository) + let currentVersion = try AppVersion( + marketingVersion: marketingVersion, + buildNumber: buildNumber + ) + + #expect(try await useCase.execute(currentVersion) == expectedResult) + #expect(await repository.fetchCallCount() == 1) + } + + @Test("필수 버전 형식이 잘못되면 invalidData 오류를 반환한다") + func 필수_버전_형식이_잘못되면_invalidData_오류를_반환한다() async throws { + let repository = AppVersionRepositorySpy(result: .success("latest")) + let useCase = CheckAppUpdateUseCaseImpl(repository) + let currentVersion = try AppVersion(marketingVersion: "1.5", buildNumber: "127") + + await #expect(throws: DomainLayerError.self) { + try await useCase.execute(currentVersion) + } + } + + @Test("필수 버전 조회 오류를 그대로 반환한다") + func 필수_버전_조회_오류를_그대로_반환한다() async throws { + let repository = AppVersionRepositorySpy( + result: .failure(AppVersionRepositoryTestError.fetchFailed) + ) + let useCase = CheckAppUpdateUseCaseImpl(repository) + let currentVersion = try AppVersion(marketingVersion: "1.5", buildNumber: "127") + + await #expect(throws: AppVersionRepositoryTestError.fetchFailed) { + try await useCase.execute(currentVersion) + } + } +} + +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 +} From a8b7ee49af0052f5ac47892e3b786f3d81a05752 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 17:57:29 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=95=B1=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/App/Sources/App/DevLogApp.swift | 1 + .../Sources/Resource/Localizable.xcstrings | 51 +++++++++++++ .../AppUpdate/CheckAppUpdateUseCase.swift | 2 +- .../AppUpdate/CheckAppUpdateUseCaseImpl.swift | 19 ++++- .../CheckAppUpdateUseCaseImplTests.swift | 54 +++++++------- .../Entry/Sources/Root/RootFeature.swift | 71 +++++++++++++++++-- .../Entry/Sources/Root/RootView.swift | 2 + .../Tests/Root/RootFeatureTestSupport.swift | 42 +++++++++++ .../Entry/Tests/Root/RootFeatureTests.swift | 44 ++++++++++++ 9 files changed, 251 insertions(+), 35 deletions(-) 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/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/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift index 1e05fd68..fbabafbf 100644 --- a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCase.swift @@ -6,5 +6,5 @@ // public protocol CheckAppUpdateUseCase { - func execute(_ currentVersion: AppVersion) async throws -> Bool + func execute() async throws -> Bool } diff --git a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift index 60016df9..576946c7 100644 --- a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift @@ -5,6 +5,8 @@ // Created by opfic on 7/22/26. // +import Foundation + public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase { private let repository: AppVersionRepository @@ -12,9 +14,24 @@ public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase { self.repository = repository } - public func execute(_ currentVersion: AppVersion) async throws -> Bool { + 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/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift index bc03313f..5e686ec8 100644 --- a/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift +++ b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift @@ -5,35 +5,27 @@ // Created by opfic on 7/22/26. // +import Foundation import Testing @testable import Domain struct CheckAppUpdateUseCaseImplTests { - @Test( - "현재 마케팅 버전과 빌드 번호를 필수 버전과 비교한다", - arguments: [ - ("1.4", "9", "1.5.1", true), - ("1.5", "127", "1.5.128", true), - ("1.5", "128", "1.5.128", false), - ("1.5", "129", "1.5.128", false), - ("1.9", "9", "1.10.1", true), - ("1.5", "1", "1.5.0", false) - ] - ) - func 현재_마케팅_버전과_빌드_번호를_필수_버전과_비교한다( - marketingVersion: String, - buildNumber: String, - requiredVersion: String, - expectedResult: Bool - ) async throws { + @Test("Bundle의 현재 버전보다 필수 버전이 높으면 업데이트가 필요하다") + func Bundle의_현재_버전보다_필수_버전이_높으면_업데이트가_필요하다() async throws { + let requiredVersion = "\(try currentVersionValue()).1" let repository = AppVersionRepositorySpy(result: .success(requiredVersion)) let useCase = CheckAppUpdateUseCaseImpl(repository) - let currentVersion = try AppVersion( - marketingVersion: marketingVersion, - buildNumber: buildNumber - ) - #expect(try await useCase.execute(currentVersion) == expectedResult) + #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) } @@ -41,10 +33,8 @@ struct CheckAppUpdateUseCaseImplTests { func 필수_버전_형식이_잘못되면_invalidData_오류를_반환한다() async throws { let repository = AppVersionRepositorySpy(result: .success("latest")) let useCase = CheckAppUpdateUseCaseImpl(repository) - let currentVersion = try AppVersion(marketingVersion: "1.5", buildNumber: "127") - await #expect(throws: DomainLayerError.self) { - try await useCase.execute(currentVersion) + try await useCase.execute() } } @@ -54,14 +44,22 @@ struct CheckAppUpdateUseCaseImplTests { result: .failure(AppVersionRepositoryTestError.fetchFailed) ) let useCase = CheckAppUpdateUseCaseImpl(repository) - let currentVersion = try AppVersion(marketingVersion: "1.5", buildNumber: "127") - await #expect(throws: AppVersionRepositoryTestError.fetchFailed) { - try await useCase.execute(currentVersion) + 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 diff --git a/Application/Presentation/Entry/Sources/Root/RootFeature.swift b/Application/Presentation/Entry/Sources/Root/RootFeature.swift index 692b4ce2..b1d52928 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,22 @@ struct RootFeature { @Dependency(\.rootNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.rootSystemThemeUseCase) var systemThemeUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.checkAppUpdateUseCase) var checkAppUpdateUseCase + @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 +90,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 +119,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 +155,11 @@ private struct RootSheetFeature: Reducer { } extension DependencyValues { + 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 +176,16 @@ extension DependencyValues { } } +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 +217,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 { [openURL] _ in + guard let url = URL(string: "https://apps.apple.com/us/app/devlog/id6760288611") else { return } + await openURL(url) + } + } + func clearApplicationBadgeCountEffect() -> Effect { .run { [setApplicationBadgeCount] _ in try? await setApplicationBadgeCount(0) @@ -220,7 +269,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..595f6640 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,8 @@ struct RootStoreTestAdapter: RootStateDriving { currentValue: .automatic ), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = RootTrackAnalyticsEventUseCaseSpy(), + checkAppUpdateUseCase: CheckAppUpdateUseCase = RootCheckAppUpdateUseCaseSpy(), + openURLSpy: RootOpenURLSpy = RootOpenURLSpy(), badgeCountSpy: RootApplicationBadgeCountSpy = RootApplicationBadgeCountSpy() ) { store = TestStore(initialState: RootFeature.State()) { @@ -72,6 +75,11 @@ struct RootStoreTestAdapter: RootStateDriving { $0.rootNetworkConnectivityUseCase = networkConnectivityUseCase $0.rootSystemThemeUseCase = systemThemeUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.checkAppUpdateUseCase = checkAppUpdateUseCase + $0.openURL = .init { url in + await openURLSpy.open(url) + return true + } $0.setApplicationBadgeCount = { count in try await badgeCountSpy.setBadgeCount(count) } @@ -120,6 +128,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 +311,36 @@ 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 + } +} + 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..a1462bdc 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift @@ -104,6 +104,50 @@ 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 { + let openSpy = RootOpenURLSpy() + let adapter = RootStoreTestAdapter( + checkAppUpdateUseCase: RootCheckAppUpdateUseCaseSpy(result: .success(true)), + openURLSpy: openSpy + ) + + await adapter.onAppear() + await adapter.tapUpdateButton() + await waitUntil { + await openSpy.openCallCount() == 1 + } + + #expect(await openSpy.openCallCount() == 1) + } + @Test("RootFeature는 TodoDetail sheet 표시와 해제를 store state로 관리한다") func RootFeature는_TodoDetail_sheet_표시와_해제를_store_state로_관리한다() async { let adapter = RootStoreTestAdapter() From d9411799919eaf4509552f807275204489cdb066 Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 18:16:25 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20App=20Store=20URL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/App/Sources/Resource/Info.plist | 2 ++ .../Entry/Sources/Root/RootFeature.swift | 27 ++++++++++++++++--- .../Tests/Root/RootFeatureTestSupport.swift | 6 +++++ .../Entry/Tests/Root/RootFeatureTests.swift | 5 +++- 4 files changed, 36 insertions(+), 4 deletions(-) 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/Presentation/Entry/Sources/Root/RootFeature.swift b/Application/Presentation/Entry/Sources/Root/RootFeature.swift index b1d52928..0e090251 100644 --- a/Application/Presentation/Entry/Sources/Root/RootFeature.swift +++ b/Application/Presentation/Entry/Sources/Root/RootFeature.swift @@ -66,6 +66,7 @@ struct RootFeature { @Dependency(\.rootSystemThemeUseCase) var systemThemeUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @Dependency(\.checkAppUpdateUseCase) var checkAppUpdateUseCase + @Dependency(\.appStoreURL) var appStoreURL @Dependency(\.openURL) var openURL @Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount @@ -155,6 +156,11 @@ 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 } @@ -176,6 +182,21 @@ 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.") @@ -225,9 +246,9 @@ private extension RootFeature { } func openAppStoreEffect() -> Effect { - .run { [openURL] _ in - guard let url = URL(string: "https://apps.apple.com/us/app/devlog/id6760288611") else { return } - await openURL(url) + .run { [appStoreURL, openURL] _ in + guard let appStoreURL else { return } + await openURL(appStoreURL) } } diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift index 595f6640..9cf7efec 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTestSupport.swift @@ -65,6 +65,7 @@ struct RootStoreTestAdapter: RootStateDriving { ), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = RootTrackAnalyticsEventUseCaseSpy(), checkAppUpdateUseCase: CheckAppUpdateUseCase = RootCheckAppUpdateUseCaseSpy(), + appStoreURL: URL? = URL(string: "https://apps.apple.com/us/app/devlog/id6760288611"), openURLSpy: RootOpenURLSpy = RootOpenURLSpy(), badgeCountSpy: RootApplicationBadgeCountSpy = RootApplicationBadgeCountSpy() ) { @@ -76,6 +77,7 @@ struct RootStoreTestAdapter: RootStateDriving { $0.rootSystemThemeUseCase = systemThemeUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.checkAppUpdateUseCase = checkAppUpdateUseCase + $0.appStoreURL = appStoreURL $0.openURL = .init { url in await openURLSpy.open(url) return true @@ -339,6 +341,10 @@ actor RootOpenURLSpy { func openCallCount() -> Int { urls.count } + + func openedURLs() -> [URL] { + urls + } } final class RootApplicationBadgeCountSpy: @unchecked Sendable { diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift index a1462bdc..87c9f0c8 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift @@ -132,10 +132,12 @@ struct RootFeatureTests { } @Test("업데이트 버튼은 App Store 열기를 요청한다") - func 업데이트_버튼은_App_Store_열기를_요청한다() async { + 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 ) @@ -146,6 +148,7 @@ struct RootFeatureTests { } #expect(await openSpy.openCallCount() == 1) + #expect(await openSpy.openedURLs() == [appStoreURL]) } @Test("RootFeature는 TodoDetail sheet 표시와 해제를 store state로 관리한다") From 9007a6d47f6a391158b10385a25baaa4e01b458c Mon Sep 17 00:00:00 2001 From: opficdev Date: Wed, 22 Jul 2026 22:47:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20Presentation=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Entry/Tests/Root/RootFeatureTests.swift | 1 + .../Presentation/Entry/Tests/Support/EntryTestSupport.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift index 87c9f0c8..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 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) } }