diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c71152d5..03685861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,8 @@ jobs: schemes: "App" - name: Domain-Data schemes: "Domain Data" + - name: Infra + schemes: "Infra" - name: Persistence schemes: "Persistence" - name: Presentation diff --git a/Application/App/Sources/Resource/Info.plist b/Application/App/Sources/Resource/Info.plist index 8e50628c..78831b61 100644 --- a/Application/App/Sources/Resource/Info.plist +++ b/Application/App/Sources/Resource/Info.plist @@ -4,6 +4,8 @@ APP_STORE_URL $(APP_STORE_URL) + APP_STORE_LOOKUP_URL + $(APP_STORE_LOOKUP_URL) TESTFLIGHT_URL $(TESTFLIGHT_URL) CFBundleDevelopmentRegion diff --git a/Application/Data/Sources/DataAssembler.swift b/Application/Data/Sources/DataAssembler.swift index f41ba3fa..58cbeba4 100644 --- a/Application/Data/Sources/DataAssembler.swift +++ b/Application/Data/Sources/DataAssembler.swift @@ -74,7 +74,7 @@ public final class DataAssembler: Assembler { container.register(AppVersionRepository.self) { AppVersionRepositoryImpl( - service: container.resolve(AppVersionConfigurationService.self) + service: container.resolve(AppStoreVersionService.self) ) } diff --git a/Application/Data/Sources/Protocol/AppStoreVersionService.swift b/Application/Data/Sources/Protocol/AppStoreVersionService.swift new file mode 100644 index 00000000..7543372b --- /dev/null +++ b/Application/Data/Sources/Protocol/AppStoreVersionService.swift @@ -0,0 +1,10 @@ +// +// AppStoreVersionService.swift +// Data +// +// Created by opfic on 7/22/26. +// + +public protocol AppStoreVersionService { + func fetchLatestVersion() async throws -> String +} diff --git a/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift b/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift deleted file mode 100644 index addbc0d6..00000000 --- a/Application/Data/Sources/Protocol/AppVersionConfigurationService.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// 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 index 6648357a..0cefc230 100644 --- a/Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift @@ -8,13 +8,13 @@ import Domain final class AppVersionRepositoryImpl: AppVersionRepository { - private let service: AppVersionConfigurationService + private let service: AppStoreVersionService - init(service: AppVersionConfigurationService) { + init(service: AppStoreVersionService) { self.service = service } - func fetchRequiredVersion() async throws -> String { - try await service.fetchRequiredVersion() + func fetchLatestVersion() async throws -> String { + try await service.fetchLatestVersion() } } diff --git a/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift b/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift index 09a11d2e..b2437960 100644 --- a/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift +++ b/Application/Data/Tests/Repository/AppVersionRepositoryImplTests.swift @@ -9,29 +9,29 @@ import Testing @testable import Data struct AppVersionRepositoryImplTests { - @Test("필수 버전 조회는 구성 서비스의 값을 반환한다") - func 필수_버전_조회는_구성_서비스의_값을_반환한다() async throws { - let service = AppVersionConfigurationServiceSpy(result: .success("1.5")) + @Test("최신 버전 조회는 App Store 서비스의 값을 반환한다") + func 최신_버전_조회는_App_Store_서비스의_값을_반환한다() async throws { + let service = AppStoreVersionServiceSpy(result: .success("1.5")) let repository = AppVersionRepositoryImpl(service: service) - #expect(try await repository.fetchRequiredVersion() == "1.5") + #expect(try await repository.fetchLatestVersion() == "1.5") #expect(await service.fetchCallCount() == 1) } - @Test("필수 버전 조회는 구성 서비스의 오류를 그대로 반환한다") - func 필수_버전_조회는_구성_서비스의_오류를_그대로_반환한다() async { - let service = AppVersionConfigurationServiceSpy( - result: .failure(AppVersionConfigurationServiceTestError.fetchFailed) + @Test("최신 버전 조회는 App Store 서비스의 오류를 그대로 반환한다") + func 최신_버전_조회는_App_Store_서비스의_오류를_그대로_반환한다() async { + let service = AppStoreVersionServiceSpy( + result: .failure(AppStoreVersionServiceTestError.fetchFailed) ) let repository = AppVersionRepositoryImpl(service: service) - await #expect(throws: AppVersionConfigurationServiceTestError.fetchFailed) { - try await repository.fetchRequiredVersion() + await #expect(throws: AppStoreVersionServiceTestError.fetchFailed) { + try await repository.fetchLatestVersion() } } } -private actor AppVersionConfigurationServiceSpy: AppVersionConfigurationService { +private actor AppStoreVersionServiceSpy: AppStoreVersionService { private let result: Result private var count = 0 @@ -39,7 +39,7 @@ private actor AppVersionConfigurationServiceSpy: AppVersionConfigurationService self.result = result } - func fetchRequiredVersion() async throws -> String { + func fetchLatestVersion() async throws -> String { count += 1 return try result.get() } @@ -49,6 +49,6 @@ private actor AppVersionConfigurationServiceSpy: AppVersionConfigurationService } } -private enum AppVersionConfigurationServiceTestError: Error { +private enum AppStoreVersionServiceTestError: Error { case fetchFailed } diff --git a/Application/Domain/Sources/Entity/AppVersion.swift b/Application/Domain/Sources/Entity/AppVersion.swift index 3fbaf06c..a5c8d578 100644 --- a/Application/Domain/Sources/Entity/AppVersion.swift +++ b/Application/Domain/Sources/Entity/AppVersion.swift @@ -8,12 +8,8 @@ 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) + public init(_ marketingVersion: String) throws { + let rawComponents = marketingVersion.split(separator: ".", omittingEmptySubsequences: false) guard !rawComponents.isEmpty, rawComponents.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { throw DomainLayerError.invalidData(context: "appVersion") diff --git a/Application/Domain/Sources/Protocol/AppVersionRepository.swift b/Application/Domain/Sources/Protocol/AppVersionRepository.swift index 6b6e6350..5bfd0647 100644 --- a/Application/Domain/Sources/Protocol/AppVersionRepository.swift +++ b/Application/Domain/Sources/Protocol/AppVersionRepository.swift @@ -6,5 +6,5 @@ // public protocol AppVersionRepository { - func fetchRequiredVersion() async throws -> String + func fetchLatestVersion() async throws -> String } diff --git a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift index 576946c7..dd97b175 100644 --- a/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/AppUpdate/CheckAppUpdateUseCaseImpl.swift @@ -15,23 +15,19 @@ public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase { } public func execute() async throws -> Bool { - let requiredVersionValue = try await repository.fetchRequiredVersion() - let requiredVersion = try AppVersion(requiredVersionValue) + let latestVersionValue = try await repository.fetchLatestVersion() + let latestVersion = try AppVersion(latestVersionValue) let currentVersion = try currentVersion() - return currentVersion < requiredVersion + return currentVersion < latestVersion } 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 { + ) as? String else { throw DomainLayerError.invalidData(context: "appVersion") } - return try AppVersion( - marketingVersion: marketingVersion, - buildNumber: buildNumber - ) + return try AppVersion(marketingVersion) } } diff --git a/Application/Domain/Tests/Entity/AppVersionTests.swift b/Application/Domain/Tests/Entity/AppVersionTests.swift index 2ff0a759..0598125e 100644 --- a/Application/Domain/Tests/Entity/AppVersionTests.swift +++ b/Application/Domain/Tests/Entity/AppVersionTests.swift @@ -12,14 +12,41 @@ struct AppVersionTests { @Test("마케팅 버전 형식이 잘못되면 invalidData 오류를 반환한다") func 마케팅_버전_형식이_잘못되면_invalidData_오류를_반환한다() { #expect(throws: DomainLayerError.self) { - try AppVersion(marketingVersion: "1..5", buildNumber: "127") + try AppVersion("1..5") } } - @Test("빌드 번호 형식이 잘못되면 invalidData 오류를 반환한다") - func 빌드_번호_형식이_잘못되면_invalidData_오류를_반환한다() { - #expect(throws: DomainLayerError.self) { - try AppVersion(marketingVersion: "1.5", buildNumber: "127a") - } + @Test("낮은 마케팅 버전은 높은 마케팅 버전보다 작다") + func 낮은_마케팅_버전은_높은_마케팅_버전보다_작다() throws { + let lowerVersion = try AppVersion("1.4.9") + let higherVersion = try AppVersion("1.5.0") + + #expect(lowerVersion < higherVersion) + } + + @Test("같은 마케팅 버전은 서로 작지 않다") + func 같은_마케팅_버전은_서로_작지_않다() throws { + let version = try AppVersion("1.5.0") + let sameVersion = try AppVersion("1.5.0") + + #expect(!(version < sameVersion)) + #expect(!(sameVersion < version)) + } + + @Test("높은 마케팅 버전은 낮은 마케팅 버전보다 작지 않다") + func 높은_마케팅_버전은_낮은_마케팅_버전보다_작지_않다() throws { + let higherVersion = try AppVersion("1.5.1") + let lowerVersion = try AppVersion("1.5.0") + + #expect(!(higherVersion < lowerVersion)) + } + + @Test("생략된 마케팅 버전 구성 요소는 0으로 비교한다") + func 생략된_마케팅_버전_구성_요소는_0으로_비교한다() throws { + let version = try AppVersion("1.5") + let expandedVersion = try AppVersion("1.5.0") + + #expect(!(version < expandedVersion)) + #expect(!(expandedVersion < version)) } } diff --git a/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift index 5e686ec8..655b9940 100644 --- a/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift +++ b/Application/Domain/Tests/UseCase/AppUpdate/CheckAppUpdateUseCaseImplTests.swift @@ -10,18 +10,18 @@ import Testing @testable import Domain struct CheckAppUpdateUseCaseImplTests { - @Test("Bundle의 현재 버전보다 필수 버전이 높으면 업데이트가 필요하다") - func Bundle의_현재_버전보다_필수_버전이_높으면_업데이트가_필요하다() async throws { - let requiredVersion = "\(try currentVersionValue()).1" - let repository = AppVersionRepositorySpy(result: .success(requiredVersion)) + @Test("Bundle의 현재 버전보다 최신 버전이 높으면 업데이트가 필요하다") + func Bundle의_현재_버전보다_최신_버전이_높으면_업데이트가_필요하다() async throws { + let latestVersion = "\(try currentVersionValue()).1" + let repository = AppVersionRepositorySpy(result: .success(latestVersion)) let useCase = CheckAppUpdateUseCaseImpl(repository) #expect(try await useCase.execute()) #expect(await repository.fetchCallCount() == 1) } - @Test("Bundle의 현재 버전과 필수 버전이 같으면 업데이트가 필요하지 않다") - func Bundle의_현재_버전과_필수_버전이_같으면_업데이트가_필요하지_않다() async throws { + @Test("Bundle의 현재 버전과 최신 버전이 같으면 업데이트가 필요하지 않다") + func Bundle의_현재_버전과_최신_버전이_같으면_업데이트가_필요하지_않다() async throws { let repository = AppVersionRepositorySpy(result: .success(try currentVersionValue())) let useCase = CheckAppUpdateUseCaseImpl(repository) @@ -29,8 +29,8 @@ struct CheckAppUpdateUseCaseImplTests { #expect(await repository.fetchCallCount() == 1) } - @Test("필수 버전 형식이 잘못되면 invalidData 오류를 반환한다") - func 필수_버전_형식이_잘못되면_invalidData_오류를_반환한다() async throws { + @Test("최신 버전 형식이 잘못되면 invalidData 오류를 반환한다") + func 최신_버전_형식이_잘못되면_invalidData_오류를_반환한다() async throws { let repository = AppVersionRepositorySpy(result: .success("latest")) let useCase = CheckAppUpdateUseCaseImpl(repository) await #expect(throws: DomainLayerError.self) { @@ -38,8 +38,8 @@ struct CheckAppUpdateUseCaseImplTests { } } - @Test("필수 버전 조회 오류를 그대로 반환한다") - func 필수_버전_조회_오류를_그대로_반환한다() async throws { + @Test("최신 버전 조회 오류를 그대로 반환한다") + func 최신_버전_조회_오류를_그대로_반환한다() async throws { let repository = AppVersionRepositorySpy( result: .failure(AppVersionRepositoryTestError.fetchFailed) ) @@ -51,13 +51,9 @@ struct CheckAppUpdateUseCaseImplTests { } private func currentVersionValue() throws -> String { - let marketingVersion = try #require( + 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 { @@ -68,7 +64,7 @@ private actor AppVersionRepositorySpy: AppVersionRepository { self.result = result } - func fetchRequiredVersion() async throws -> String { + func fetchLatestVersion() async throws -> String { count += 1 return try result.get() } diff --git a/Application/Infra/Sources/Common/InfraLayerError.swift b/Application/Infra/Sources/Common/InfraLayerError.swift index bcd092d9..c9892578 100644 --- a/Application/Infra/Sources/Common/InfraLayerError.swift +++ b/Application/Infra/Sources/Common/InfraLayerError.swift @@ -25,6 +25,13 @@ enum SocialLoginError: Error { case authenticationAlreadyInProgress } +enum AppVersionServiceError: Error, Equatable { + case missingLookupURL + case invalidLookupURL + case missingVersion + case invalidVersion +} + extension Error { var isSocialLoginCancelled: Bool { switch self { diff --git a/Application/Infra/Sources/InfraAssembler.swift b/Application/Infra/Sources/InfraAssembler.swift index 1ed91f78..0645f606 100644 --- a/Application/Infra/Sources/InfraAssembler.swift +++ b/Application/Infra/Sources/InfraAssembler.swift @@ -16,8 +16,8 @@ public final class InfraAssembler: Assembler { FirebaseAppServiceImpl() } - container.register(AppVersionConfigurationService.self) { - RemoteConfigAppVersionServiceImpl() + container.register(AppStoreVersionService.self) { + ITunesAppVersionServiceImpl() } container.register(AnalyticsService.self) { diff --git a/Application/Infra/Sources/Service/ITunesAppVersionServiceImpl.swift b/Application/Infra/Sources/Service/ITunesAppVersionServiceImpl.swift new file mode 100644 index 00000000..89d04e31 --- /dev/null +++ b/Application/Infra/Sources/Service/ITunesAppVersionServiceImpl.swift @@ -0,0 +1,94 @@ +// +// ITunesAppVersionServiceImpl.swift +// Infra +// +// Created by opfic on 7/24/26. +// + +import Data +import Foundation +import Nexa + +final class ITunesAppVersionServiceImpl: AppStoreVersionService { + private enum InfoKey { + static let lookupURL = "APP_STORE_LOOKUP_URL" + } + + func fetchLatestVersion() async throws -> String { + let url = try Self.lookupURL( + from: Bundle.main.object( + forInfoDictionaryKey: InfoKey.lookupURL + ) as? String + ) + let client = NXAPIClient( + configuration: NXClientConfiguration(baseURL: url) + ) + let lookupResponse = try await client.send( + ITunesLookupEndpoint( + timestamp: Int(Date().timeIntervalSince1970) + ) + ) + + guard let version = lookupResponse.version else { + throw AppVersionServiceError.missingVersion + } + + return try Self.normalizedVersion(version) + } + + static func lookupURL(from lookupURLString: String?) throws -> URL { + guard let lookupURLString = normalizedLookupURLString( + lookupURLString + ) else { + throw AppVersionServiceError.missingLookupURL + } + + guard var components = URLComponents(string: lookupURLString), + components.scheme?.lowercased() == "https", + components.host != nil else { + throw AppVersionServiceError.invalidLookupURL + } + + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == ITunesLookupEndpoint.timestampQueryKey } + components.queryItems = queryItems + + guard let url = components.url else { + throw AppVersionServiceError.invalidLookupURL + } + + return url + } + + private static func normalizedLookupURLString( + _ lookupURLString: String? + ) -> String? { + guard let lookupURLString else { return nil } + + let value = lookupURLString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, !value.hasPrefix("$(") else { + return nil + } + + return value + } + + static func normalizedVersion(_ version: String) throws -> String { + let value = version.trimmingCharacters(in: .whitespacesAndNewlines) + let components = value.split( + separator: ".", + omittingEmptySubsequences: false + ) + let isValid = !components.isEmpty && components.allSatisfy { component in + !component.isEmpty && component.unicodeScalars.allSatisfy { + 48 <= $0.value && $0.value <= 57 + } + } + + guard isValid else { + throw AppVersionServiceError.invalidVersion + } + + return value + } +} diff --git a/Application/Infra/Sources/Service/ITunesLookupEndpoint.swift b/Application/Infra/Sources/Service/ITunesLookupEndpoint.swift new file mode 100644 index 00000000..0220f6fa --- /dev/null +++ b/Application/Infra/Sources/Service/ITunesLookupEndpoint.swift @@ -0,0 +1,23 @@ +// +// ITunesLookupEndpoint.swift +// Infra +// +// Created by opfic on 7/24/26. +// + +import Nexa + +struct ITunesLookupEndpoint: NXEndpoint { + static let timestampQueryKey = "timestamp" + let timestamp: Int + var method: NXHTTPMethod { .get } + var path: String { "" } + + func configure( + _ builder: NXTypedRequestBuilder + ) -> NXTypedRequestBuilder { + builder + .query(Self.timestampQueryKey, timestamp) + .accept("application/json") + } +} diff --git a/Application/Infra/Sources/Service/ITunesLookupResponse.swift b/Application/Infra/Sources/Service/ITunesLookupResponse.swift new file mode 100644 index 00000000..91a91904 --- /dev/null +++ b/Application/Infra/Sources/Service/ITunesLookupResponse.swift @@ -0,0 +1,31 @@ +// +// ITunesLookupResponse.swift +// Infra +// +// Created by opfic on 7/24/26. +// + +struct ITunesLookupResponse: Decodable { + private enum CodingKeys: String, CodingKey { + case results + } + + private enum ResultCodingKeys: String, CodingKey { + case version + } + + let version: String? + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var results = try container.nestedUnkeyedContainer(forKey: .results) + + guard !results.isAtEnd else { + version = nil + return + } + + let result = try results.nestedContainer(keyedBy: ResultCodingKeys.self) + version = try result.decodeIfPresent(String.self, forKey: .version) + } +} diff --git a/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift b/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift deleted file mode 100644 index ec0653e4..00000000 --- a/Application/Infra/Sources/Service/RemoteConfigAppVersionServiceImpl.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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/Infra/Tests/Service/ITunesAppVersionServiceImplTests.swift b/Application/Infra/Tests/Service/ITunesAppVersionServiceImplTests.swift new file mode 100644 index 00000000..da409726 --- /dev/null +++ b/Application/Infra/Tests/Service/ITunesAppVersionServiceImplTests.swift @@ -0,0 +1,124 @@ +// +// ITunesAppVersionServiceImplTests.swift +// InfraTests +// +// Created by opfic on 7/24/26. +// + +import Foundation +import Nexa +import Testing +@testable import Infra + +struct ITunesAppVersionServiceImplTests { + @Test("Nexa 요청은 기존 쿼리를 유지하고 timestamp를 교체한다") + func Nexa_요청은_기존_쿼리를_유지하고_timestamp를_교체한다() async throws { + let url = try ITunesAppVersionServiceImpl.lookupURL( + from: "https://itunes.apple.com/lookup?id=6760288611&country=KR×tamp=1" + ) + let client = NXAPIClient( + configuration: NXClientConfiguration(baseURL: url) + ) + let request = try await client + .request(ITunesLookupEndpoint(timestamp: 1_722_000_000)) + .preparedURLRequest() + let requestURL = try #require(request.url) + let queryItems = try #require( + URLComponents( + url: requestURL, + resolvingAgainstBaseURL: false + )?.queryItems + ) + + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(queryItems.first { $0.name == "id" }?.value == "6760288611") + #expect(queryItems.first { $0.name == "country" }?.value == "KR") + #expect( + queryItems.filter { $0.name == "timestamp" }.map(\.value) == [ + "1722000000" + ] + ) + } + + @Test("조회 URL이 없거나 비어 있거나 미해결 값이면 실패한다") + func 조회_URL이_없거나_비어_있거나_미해결_값이면_실패한다() { + let lookupURLStrings: [String?] = [ + nil, + "", + " \n ", + "$(APP_STORE_LOOKUP_URL)" + ] + + for lookupURLString in lookupURLStrings { + #expect(throws: AppVersionServiceError.missingLookupURL) { + try ITunesAppVersionServiceImpl.lookupURL( + from: lookupURLString + ) + } + } + } + + @Test("잘못된 조회 URL이면 실패한다") + func 잘못된_조회_URL이면_실패한다() { + #expect(throws: AppVersionServiceError.invalidLookupURL) { + try ITunesAppVersionServiceImpl.lookupURL( + from: "itunes.apple.com/lookup?id=6760288611" + ) + } + } + + @Test("iTunes 응답에서 첫 버전을 추출한다") + func iTunes_응답에서_첫_버전을_추출한다() throws { + let data = Data( + #"{"results":[{"version":"1.2.3"},{"version":"2.0.0"}]}"#.utf8 + ) + + let response = try JSONDecoder().decode( + ITunesLookupResponse.self, + from: data + ) + + #expect(response.version == "1.2.3") + } + + @Test("iTunes 결과가 비어 있으면 버전이 없다") + func iTunes_결과가_비어_있으면_버전이_없다() throws { + let data = Data(#"{"results":[]}"#.utf8) + + let response = try JSONDecoder().decode( + ITunesLookupResponse.self, + from: data + ) + + #expect(response.version == nil) + } + + @Test("iTunes JSON 형식이 잘못되면 디코딩에 실패한다") + func iTunes_JSON_형식이_잘못되면_디코딩에_실패한다() { + let data = Data("invalid".utf8) + + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + ITunesLookupResponse.self, + from: data + ) + } + } + + @Test("버전 앞뒤 공백을 제거한다") + func 버전_앞뒤_공백을_제거한다() throws { + #expect( + try ITunesAppVersionServiceImpl.normalizedVersion( + " 1.2.3 \n" + ) == "1.2.3" + ) + } + + @Test("버전 형식이 잘못되면 실패한다", arguments: ["", "1.beta.0", "1..0"]) + func 버전_형식이_잘못되면_실패한다(version: String) { + #expect(throws: AppVersionServiceError.invalidVersion) { + try ITunesAppVersionServiceImpl.normalizedVersion(version) + } + } +} diff --git a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift index 8ed7a86a..e99ca7e3 100644 --- a/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Root/RootFeatureTests.swift @@ -119,6 +119,20 @@ struct RootFeatureTests { #expect(await checkSpy.executeCallCount() == 1) } + @Test("업데이트 확인에 실패하면 업데이트 알림을 표시하지 않는다") + func 업데이트_확인에_실패하면_업데이트_알림을_표시하지_않는다() async { + let checkSpy = RootCheckAppUpdateUseCaseSpy( + result: .failure(RootCheckAppUpdateUseCaseTestError.fetchFailed) + ) + let adapter = RootStoreTestAdapter(checkAppUpdateUseCase: checkSpy) + + await adapter.onAppear() + + #expect(await checkSpy.executeCallCount() == 1) + #expect(adapter.snapshot.alertTitle == nil) + #expect(adapter.snapshot.alertMessage == nil) + } + @Test("필수 업데이트 알림은 네트워크 연결 알림보다 우선한다") func 필수_업데이트_알림은_네트워크_연결_알림보다_우선한다() async { let adapter = RootStoreTestAdapter( @@ -166,3 +180,7 @@ struct RootFeatureTests { await verifyWidgetRouteOpensWhenSignedIn(adapter: adapter) } } + +private enum RootCheckAppUpdateUseCaseTestError: Error { + case fetchFailed +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index 0da16a9c..bde4bbf6 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -36,7 +36,6 @@ public enum DevLogPackages { .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), - .package(product: "FirebaseRemoteConfig"), .package(product: "Nexa"), ]