diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata index da356cdc..4309165a 100644 --- a/MLS/MLS.xcworkspace/contents.xcworkspacedata +++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata @@ -35,6 +35,9 @@ + + diff --git a/MLS/MLSAppFeature/.claude/settings.local.json b/MLS/MLSAppFeature/.claude/settings.local.json new file mode 100644 index 00000000..070af57c --- /dev/null +++ b/MLS/MLSAppFeature/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(swift build:*)", + "Bash(rm:*)", + "Bash(mkdir:*)", + "Bash(swift test:*)", + "Bash(find:*)" + ] + } +} diff --git a/MLS/MLSAppFeature/.gitignore b/MLS/MLSAppFeature/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/MLS/MLSAppFeature/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/MLS/MLSAppFeature/Package.swift b/MLS/MLSAppFeature/Package.swift new file mode 100644 index 00000000..895561cb --- /dev/null +++ b/MLS/MLSAppFeature/Package.swift @@ -0,0 +1,52 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MLSAppFeature", + platforms: [.iOS(.v15)], + products: [ + // Interface: 외부 인터페이스와 모델을 제공하는 모듈 + .library( + name: "MLSAppFeatureInterface", + targets: ["MLSAppFeatureInterface"] + ), + // Feature: 실제 기능이 구현된 모듈 + .library( + name: "MLSAppFeature", + targets: ["MLSAppFeature"] + ), + // Testing: 단위 테스트나 Example 앱에서 사용될 Mock 데이터를 제공하는 모듈 + .library( + name: "MLSAppFeatureTesting", + targets: ["MLSAppFeatureTesting"] + ) + ], + targets: [ + // Interface 모듈 (도메인 모델 및 프로토콜) + .target( + name: "MLSAppFeatureInterface", + dependencies: [] + ), + // Feature 모듈 (실제 구현) + .target( + name: "MLSAppFeature", + dependencies: ["MLSAppFeatureInterface"] + ), + // Testing 모듈 (Mock 객체) + .target( + name: "MLSAppFeatureTesting", + dependencies: ["MLSAppFeatureInterface"] + ), + // Tests 모듈 + .testTarget( + name: "MLSAppFeatureTests", + dependencies: [ + "MLSAppFeature", + "MLSAppFeatureInterface", + "MLSAppFeatureTesting" + ] + ) + ] +) diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift new file mode 100644 index 00000000..1f314b3d --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift @@ -0,0 +1,41 @@ +import Foundation + +import MLSAppFeatureInterface + +/// UserDefaults를 사용하는 Local Data Source +final class UserDefaultsDataSource { + nonisolated(unsafe) private let userDefaults: UserDefaults + private let versionKey = "com.mls.updateChecker.skippedVersion" + private let dateKey = "com.mls.updateChecker.skippedDate" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + /// 스킵 버전을 저장합니다 + func saveSkipVersion(_ version: Version, skippedAt date: Date) { + userDefaults.set(version.versionString, forKey: versionKey) + userDefaults.set(date.timeIntervalSince1970, forKey: dateKey) + } + + /// 저장된 스킵 버전을 조회합니다 + func getSkippedVersion() -> Version? { + guard let versionString = userDefaults.string(forKey: versionKey) else { + return nil + } + return Version(versionString: versionString) + } + + /// 스킵 날짜를 조회합니다 + func getSkippedDate() -> Date? { + let timestamp = userDefaults.double(forKey: dateKey) + guard timestamp > 0 else { return nil } + return Date(timeIntervalSince1970: timestamp) + } + + /// 스킵 정보를 삭제합니다 + func clearSkipInfo() { + userDefaults.removeObject(forKey: versionKey) + userDefaults.removeObject(forKey: dateKey) + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift new file mode 100644 index 00000000..b3ffb71a --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift @@ -0,0 +1,50 @@ +import Foundation + +import MLSAppFeatureInterface + +/// iTunes Search API를 통해 앱스토어 버전 정보를 조회하는 Remote Data Source +final class AppStoreService { + private let urlSession: URLSession + + init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + } + + /// 앱스토어에서 최신 버전을 조회합니다 + /// - Parameter appID: 앱스토어 앱 ID + /// - Returns: 최신 버전 정보 + /// - Throws: AppStoreError + func fetchLatestVersion(appID: String) async throws -> Version { + guard let url = URL(string: "https://itunes.apple.com/lookup?id=\(appID)") else { + throw AppStoreError.invalidURL + } + + let (data, response) = try await urlSession.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw AppStoreError.invalidResponse + } + + guard let lookupResponse = try? JSONDecoder().decode(AppStoreLookupResponse.self, from: data) else { + throw AppStoreError.parsingError + } + + guard let result = lookupResponse.results.first, + let version = Version(versionString: result.version) else { + throw AppStoreError.versionNotFound + } + + return version + } +} + +// MARK: - Response Models + +private struct AppStoreLookupResponse: Decodable { + let results: [AppStoreResult] +} + +private struct AppStoreResult: Decodable { + let version: String +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift new file mode 100644 index 00000000..4f3a6508 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift @@ -0,0 +1,21 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 앱스토어 정보를 조회하는 Repository 구현체 +public final class AppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable { + private let remoteDataSource: AppStoreService + + public init() { + self.remoteDataSource = AppStoreService() + } + + /// 테스트용 초기화자 + init(remoteDataSource: AppStoreService) { + self.remoteDataSource = remoteDataSource + } + + public func fetchLatestVersion(appID: String) async throws -> Version { + return try await remoteDataSource.fetchLatestVersion(appID: appID) + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift new file mode 100644 index 00000000..0b5692f5 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift @@ -0,0 +1,45 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 업데이트 스킵 정보를 관리하는 Repository 구현체 +public final class UpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable { + private let localDataSource: UserDefaultsDataSource + private let skipDuration: TimeInterval + + /// UpdateSkipRepository 초기화 + /// - Parameter skipDuration: 스킵 유효 기간 (기본값: 7일) + public init(skipDuration: TimeInterval = 7 * 24 * 60 * 60) { + self.localDataSource = UserDefaultsDataSource() + self.skipDuration = skipDuration + } + + /// 테스트용 초기화자 + /// - Parameters: + /// - localDataSource: 로컬 데이터 소스 + /// - skipDuration: 스킵 유효 기간 + init(localDataSource: UserDefaultsDataSource, skipDuration: TimeInterval = 7 * 24 * 60 * 60) { + self.localDataSource = localDataSource + self.skipDuration = skipDuration + } + + public func saveSkipVersion(_ version: Version, skippedAt date: Date) { + localDataSource.saveSkipVersion(version, skippedAt: date) + } + + public func isSkipValid(for version: Version) -> Bool { + guard let skippedVersion = localDataSource.getSkippedVersion(), + let skippedDate = localDataSource.getSkippedDate(), + skippedVersion == version else { + return false + } + + // 스킵 기간이 지났는지 확인 + let elapsed = Date().timeIntervalSince(skippedDate) + return elapsed >= 0 && elapsed < skipDuration + } + + public func clearSkipInfo() { + localDataSource.clearSkipInfo() + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift new file mode 100644 index 00000000..b3c20b1b --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift @@ -0,0 +1,55 @@ +import Foundation + +import MLSAppFeatureInterface + +/// 앱 업데이트 체크 Use Case 구현체 +public final class UpdateCheckerUseCase: UpdateCheckerUseCaseProtocol { + private let appStoreRepository: AppStoreRepositoryProtocol + private let skipRepository: UpdateSkipRepositoryProtocol + private let appID: String + + /// UpdateCheckerUseCase 초기화 + /// - Parameters: + /// - appID: 앱스토어 앱 ID + /// - appStoreRepository: 앱스토어 정보 조회 Repository + /// - skipRepository: 스킵 정보 관리 Repository + public init( + appID: String, + appStoreRepository: AppStoreRepositoryProtocol, + skipRepository: UpdateSkipRepositoryProtocol + ) { + self.appID = appID + self.appStoreRepository = appStoreRepository + self.skipRepository = skipRepository + } + + public func checkUpdate(currentVersion: Version) async throws -> UpdateStatus { + let latestVersion = try await appStoreRepository.fetchLatestVersion(appID: appID) + + // 최신 버전이 현재 버전보다 낮거나 같으면 업데이트 불필요 + guard latestVersion > currentVersion else { + return .none + } + + // major 버전이 다르면 강제 업데이트 + if latestVersion.major != currentVersion.major { + return .force(latestVersion: latestVersion) + } + + // minor 또는 patch 차이면 선택 업데이트 + // 단, 사용자가 이전에 스킵했고 7일이 지나지 않았으면 none 반환 + if skipRepository.isSkipValid(for: latestVersion) { + return .none + } + + return .optional(latestVersion: latestVersion) + } + + public func skipUpdate(version: Version) { + skipRepository.saveSkipVersion(version, skippedAt: Date()) + } + + public func clearSkipInfo() { + skipRepository.clearSkipInfo() + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift new file mode 100644 index 00000000..e9707969 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift @@ -0,0 +1,13 @@ +import Foundation + +/// 앱 업데이트 상태를 나타내는 열거형 +public enum UpdateStatus: Equatable, Sendable { + /// 강제 업데이트 필요 (major 버전 차이) + case force(latestVersion: Version) + + /// 선택적 업데이트 가능 (minor 또는 patch 차이) + case optional(latestVersion: Version) + + /// 업데이트 불필요 + case none +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift new file mode 100644 index 00000000..ae647174 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift @@ -0,0 +1,38 @@ +import Foundation + +/// 앱 버전을 나타내는 도메인 엔티티 +/// major.minor.patch 형식의 시맨틱 버저닝을 지원합니다 +public struct Version: Equatable, Comparable, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + /// 버전 문자열로부터 Version 객체를 생성합니다 + /// - Parameter versionString: "1.2.3" 형식의 버전 문자열 + /// - Returns: 파싱에 성공하면 Version 객체, 실패하면 nil + public init?(versionString: String) { + let components = versionString.split(separator: ".").compactMap { Int($0) } + guard components.count >= 2 else { return nil } + + self.major = components[0] + self.minor = components[1] + self.patch = components.count > 2 ? components[2] : 0 + } + + /// 버전을 문자열로 반환합니다 + public var versionString: String { + "\(major).\(minor).\(patch)" + } + + public static func < (lhs: Version, rhs: Version) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift new file mode 100644 index 00000000..dea53755 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift @@ -0,0 +1,19 @@ +import Foundation + +/// 앱스토어 정보를 조회하는 Repository 프로토콜 +public protocol AppStoreRepositoryProtocol: Sendable { + /// 앱스토어에서 최신 버전을 조회합니다 + /// - Parameter appID: 앱스토어 앱 ID + /// - Returns: 최신 버전 정보 + /// - Throws: 조회 실패 시 AppStoreError + func fetchLatestVersion(appID: String) async throws -> Version +} + +/// 앱스토어 조회 시 발생할 수 있는 에러 +public enum AppStoreError: Error, Equatable, Sendable { + case invalidURL + case networkFailure + case invalidResponse + case versionNotFound + case parsingError +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift new file mode 100644 index 00000000..c2f840a3 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift @@ -0,0 +1,18 @@ +import Foundation + +/// 업데이트 스킵 정보를 관리하는 Repository 프로토콜 +public protocol UpdateSkipRepositoryProtocol: Sendable { + /// 특정 버전에 대한 스킵 정보를 저장합니다 + /// - Parameters: + /// - version: 스킵할 버전 + /// - date: 스킵 날짜 + func saveSkipVersion(_ version: Version, skippedAt date: Date) + + /// 특정 버전에 대한 스킵 정보가 유효한지 확인합니다 + /// - Parameter version: 확인할 버전 + /// - Returns: 스킵 정보가 유효하면 true (7일 이내) + func isSkipValid(for version: Version) -> Bool + + /// 저장된 스킵 정보를 삭제합니다 + func clearSkipInfo() +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift new file mode 100644 index 00000000..12583c07 --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift @@ -0,0 +1,17 @@ +import Foundation + +/// 앱 업데이트 체크를 담당하는 Use Case 프로토콜 +public protocol UpdateCheckerUseCaseProtocol: Sendable { + /// 현재 버전과 앱스토어의 최신 버전을 비교하여 업데이트 상태를 반환합니다 + /// - Parameter currentVersion: 현재 앱 버전 + /// - Returns: 업데이트 상태 (force, optional, none) + /// - Throws: 앱스토어 조회 실패 시 에러 + func checkUpdate(currentVersion: Version) async throws -> UpdateStatus + + /// 사용자가 선택 업데이트를 스킵했을 때 호출합니다 + /// - Parameter version: 스킵할 버전 + func skipUpdate(version: Version) + + /// 스킵 정보를 초기화합니다 + func clearSkipInfo() +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift new file mode 100644 index 00000000..29044c2b --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift @@ -0,0 +1,32 @@ +import Foundation +import MLSAppFeatureInterface + +/// 테스트용 MockAppStoreRepository +public final class MockAppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable { + public var mockVersion: Version? + public var mockError: Error? + public var fetchCallCount = 0 + + public init() {} + + public func fetchLatestVersion(appID: String) async throws -> Version { + fetchCallCount += 1 + + if let error = mockError { + throw error + } + + guard let version = mockVersion else { + throw AppStoreError.versionNotFound + } + + return version + } + + /// Mock 데이터를 초기화합니다 + public func reset() { + mockVersion = nil + mockError = nil + fetchCallCount = 0 + } +} diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift new file mode 100644 index 00000000..5beebb5d --- /dev/null +++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift @@ -0,0 +1,62 @@ +import Foundation +import MLSAppFeatureInterface + +/// 테스트용 MockUpdateSkipRepository +public final class MockUpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable { + private var skippedVersion: Version? + private var skippedDate: Date? + public var skipDuration: TimeInterval = 7 * 24 * 60 * 60 + + public var saveCallCount = 0 + public var clearCallCount = 0 + + public init() {} + + public func saveSkipVersion(_ version: Version, skippedAt date: Date) { + saveCallCount += 1 + skippedVersion = version + skippedDate = date + } + + public func isSkipValid(for version: Version) -> Bool { + guard let skippedVersion = skippedVersion, + let skippedDate = skippedDate else { + return false + } + + guard skippedVersion == version else { + return false + } + + let currentDate = Date() + let elapsedTime = currentDate.timeIntervalSince(skippedDate) + + return elapsedTime < skipDuration + } + + public func clearSkipInfo() { + clearCallCount += 1 + skippedVersion = nil + skippedDate = nil + } + + /// Mock 데이터를 초기화합니다 + public func reset() { + skippedVersion = nil + skippedDate = nil + saveCallCount = 0 + clearCallCount = 0 + } + + // MARK: - Test Helpers + + /// 테스트용 헬퍼 메서드 + public func getSkippedVersion() -> Version? { + return skippedVersion + } + + /// 테스트용 헬퍼 메서드 + public func getSkippedDate() -> Date? { + return skippedDate + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift new file mode 100644 index 00000000..c8aa5079 --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +@testable import MLSAppFeature +@testable import MLSAppFeatureInterface + +/// UpdateSkipRepository의 스킵 정보 저장, 조회, 유효성 검증 기능을 테스트합니다 +@Suite("UpdateSkipRepository 테스트") +struct UpdateSkipRepositoryTests { + + /// 테스트용 UserDefaults와 Repository를 생성합니다 + func createRepository(suiteName: String = "com.mls.test.skip") -> (UpdateSkipRepository, UserDefaults) { + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults.synchronize() + + let dataSource = UserDefaultsDataSource(userDefaults: userDefaults) + let repository = UpdateSkipRepository(localDataSource: dataSource) + + return (repository, userDefaults) + } + + // MARK: - 저장 및 조회 테스트 + + /// 스킵 정보를 저장하고 유효성 검증이 올바른지 확인합니다 + @Test("스킵 정보 저장 및 유효성 검증") + func saveAndValidateSkip() { + // Given + let suiteName = "com.mls.test.skip.save" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + + // When: 버전 스킵 저장 + repository.saveSkipVersion(version, skippedAt: Date()) + + // Then: 스킵 정보가 유효함 + #expect(repository.isSkipValid(for: version)) + } + + // MARK: - 7일 정책 테스트 + + /// 스킵한 지 3일 이내인 경우 유효한지 확인합니다 + @Test("스킵 유효: 7일 이내 (3일 전)") + func skipValidWithinDuration() { + // Given: 3일 전에 스킵함 + let suiteName = "com.mls.test.skip.valid" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + let threeDaysAgo = Date().addingTimeInterval(-3 * 24 * 60 * 60) + + // When + repository.saveSkipVersion(version, skippedAt: threeDaysAgo) + + // Then: 7일 이내이므로 유효 + #expect(repository.isSkipValid(for: version)) + } + + /// 스킵한 지 8일이 지난 경우 무효한지 확인합니다 + @Test("스킵 무효: 7일 초과 (8일 전)") + func skipInvalidAfterDuration() { + // Given: 8일 전에 스킵함 + let suiteName = "com.mls.test.skip.expired" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60) + + // When + repository.saveSkipVersion(version, skippedAt: eightDaysAgo) + + // Then: 7일을 초과했으므로 무효 + #expect(!repository.isSkipValid(for: version)) + } + + // MARK: - 버전 불일치 테스트 + + /// 스킵한 버전과 다른 버전을 체크하면 무효한지 확인합니다 + @Test("스킵 무효: 버전 불일치") + func skipInvalidForDifferentVersion() { + // Given: v1.2.0을 스킵함 + let suiteName = "com.mls.test.skip.diff" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let v1 = Version(major: 1, minor: 2, patch: 0) + let v2 = Version(major: 1, minor: 3, patch: 0) + repository.saveSkipVersion(v1, skippedAt: Date()) + + // When/Then: v1.3.0은 스킵되지 않았으므로 무효 + #expect(!repository.isSkipValid(for: v2)) + } + + // MARK: - 스킵 정보 삭제 테스트 + + /// clearSkipInfo 호출 시 스킵 정보가 삭제되는지 확인합니다 + @Test("스킵 정보 삭제") + func clearSkipInfo() { + // Given: 스킵 정보 저장 + let suiteName = "com.mls.test.skip.clear" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 1, minor: 2, patch: 0) + repository.saveSkipVersion(version, skippedAt: Date()) + + // When: 스킵 정보 삭제 + repository.clearSkipInfo() + + // Then: 스킵 정보가 무효화됨 + #expect(!repository.isSkipValid(for: version)) + } + + // MARK: - 영속성 테스트 + + /// UserDefaults에 저장된 데이터가 다른 Repository 인스턴스에서도 조회되는지 확인합니다 + @Test("UserDefaults 영속성") + func persistence() { + // Given: Repository1에서 스킵 정보 저장 + let suiteName = "com.mls.test.skip.persistence" + let (repository, userDefaults) = createRepository(suiteName: suiteName) + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let version = Version(major: 2, minor: 5, patch: 1) + repository.saveSkipVersion(version, skippedAt: Date()) + + // When: 새로운 Repository2 생성 (같은 UserDefaults 사용) + let dataSource2 = UserDefaultsDataSource(userDefaults: userDefaults) + let repository2 = UpdateSkipRepository(localDataSource: dataSource2) + + // Then: Repository2에서도 스킵 정보 조회 가능 + #expect(repository2.isSkipValid(for: version)) + } + + // MARK: - 커스텀 기간 테스트 + + /// 스킵 기간을 커스터마이징할 수 있는지 확인합니다 + @Test("커스텀 스킵 기간: 1일") + func customDuration() { + // Given: 스킵 기간을 1일로 설정 + let suiteName = "com.mls.test.skip.custom" + let userDefaults = UserDefaults(suiteName: suiteName)! + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults.synchronize() + defer { userDefaults.removePersistentDomain(forName: suiteName) } + + let dataSource = UserDefaultsDataSource(userDefaults: userDefaults) + let customRepo = UpdateSkipRepository(localDataSource: dataSource, skipDuration: 1 * 24 * 60 * 60) + + let version = Version(major: 1, minor: 2, patch: 0) + let twoDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + // When: 2일 전에 스킵함 + customRepo.saveSkipVersion(version, skippedAt: twoDaysAgo) + + // Then: 1일 기준으로는 무효 + #expect(!customRepo.isSkipValid(for: version)) + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift new file mode 100644 index 00000000..9a0e875e --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing + +@testable import MLSAppFeature +@testable import MLSAppFeatureInterface +@testable import MLSAppFeatureTesting + +/// UpdateChecker Use Case의 업데이트 판단 로직과 스킵 기능을 테스트합니다 +@Suite("UpdateChecker Use Case 테스트") +struct UpdateCheckerUseCaseTests { + let mockAppStoreRepository: MockAppStoreRepository + let mockSkipRepository: MockUpdateSkipRepository + let useCase: UpdateCheckerUseCase + + /// 각 테스트마다 새로운 Mock 객체와 UseCase를 생성합니다 + init() { + mockAppStoreRepository = MockAppStoreRepository() + mockSkipRepository = MockUpdateSkipRepository() + useCase = UpdateCheckerUseCase( + appID: "123456789", + appStoreRepository: mockAppStoreRepository, + skipRepository: mockSkipRepository + ) + } + + // MARK: - 강제 업데이트 테스트 + + /// major 버전이 다를 때 강제 업데이트가 반환되는지 확인합니다 + @Test("강제 업데이트: major 버전 차이") + func forceUpdate() async throws { + // Given: 앱스토어 버전이 2.0.0, 현재 버전이 1.0.0 + mockAppStoreRepository.mockVersion = Version(major: 2, minor: 0, patch: 0) + let currentVersion = Version(major: 1, minor: 0, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 강제 업데이트 상태 + guard case .force(let latest) = status else { + Issue.record("Expected force update status") + return + } + #expect(latest == Version(major: 2, minor: 0, patch: 0)) + } + + // MARK: - 선택 업데이트 테스트 + + /// minor 버전이 다를 때 선택 업데이트가 반환되는지 확인합니다 + @Test("선택 업데이트: minor 버전 차이") + func optionalUpdate() async throws { + // Given: 앱스토어 버전이 1.2.0, 현재 버전이 1.1.0 + mockAppStoreRepository.mockVersion = Version(major: 1, minor: 2, patch: 0) + let currentVersion = Version(major: 1, minor: 1, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 선택 업데이트 상태 + guard case .optional(let latest) = status else { + Issue.record("Expected optional update status") + return + } + #expect(latest == Version(major: 1, minor: 2, patch: 0)) + } + + // MARK: - 업데이트 불필요 테스트 + + /// 현재 버전과 최신 버전이 같을 때 none이 반환되는지 확인합니다 + @Test("업데이트 불필요: 동일한 버전") + func noneUpdate() async throws { + // Given: 앱스토어 버전과 현재 버전이 모두 1.0.0 + mockAppStoreRepository.mockVersion = Version(major: 1, minor: 0, patch: 0) + let currentVersion = Version(major: 1, minor: 0, patch: 0) + + // When: 업데이트 체크 + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 업데이트 불필요 + #expect(status == .none) + } + + // MARK: - 스킵 로직 테스트 + + /// 사용자가 스킵한 버전이 7일 이내인 경우 none이 반환되는지 확인합니다 + @Test("스킵 로직: 유효한 스킵 (7일 이내)") + func skipLogicValid() async throws { + // Given: 최신 버전 1.2.0을 오늘 스킵함 + let latest = Version(major: 1, minor: 2, patch: 0) + mockAppStoreRepository.mockVersion = latest + mockSkipRepository.saveSkipVersion(latest, skippedAt: Date()) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 1, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵이 유효하므로 none 반환 + #expect(status == .none) + } + + /// 스킵한 지 7일이 지난 경우 다시 optional이 반환되는지 확인합니다 + @Test("스킵 로직: 만료된 스킵 (7일 초과)") + func skipLogicExpired() async throws { + // Given: 최신 버전 1.2.0을 8일 전에 스킵함 + let latest = Version(major: 1, minor: 2, patch: 0) + mockAppStoreRepository.mockVersion = latest + let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60) + mockSkipRepository.saveSkipVersion(latest, skippedAt: eightDaysAgo) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 1, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵이 만료되어 optional 반환 + guard case .optional = status else { + Issue.record("Expected optional update status") + return + } + } + + /// 강제 업데이트는 스킵 정보를 무시하고 항상 force를 반환하는지 확인합니다 + @Test("스킵 로직: 강제 업데이트는 스킵 무시") + func forceUpdateIgnoresSkip() async throws { + // Given: 최신 버전 2.0.0을 스킵했지만 major 버전 차이 + let latest = Version(major: 2, minor: 0, patch: 0) + mockAppStoreRepository.mockVersion = latest + mockSkipRepository.saveSkipVersion(latest, skippedAt: Date()) + + // When: 업데이트 체크 + let currentVersion = Version(major: 1, minor: 0, patch: 0) + let status = try await useCase.checkUpdate(currentVersion: currentVersion) + + // Then: 스킵을 무시하고 강제 업데이트 반환 + guard case .force = status else { + Issue.record("Expected force update status") + return + } + } + + // MARK: - 스킵 관리 테스트 + + /// skipUpdate 호출 시 Repository에 저장이 요청되는지 확인합니다 + @Test("스킵 저장 기능") + func skipUpdate() { + // Given + let version = Version(major: 1, minor: 2, patch: 0) + + // When: 버전 스킵 + useCase.skipUpdate(version: version) + + // Then: Repository의 save가 호출됨 + #expect(mockSkipRepository.saveCallCount == 1) + } + + /// clearSkipInfo 호출 시 Repository의 clear가 호출되는지 확인합니다 + @Test("스킵 정보 초기화") + func clearSkipInfo() { + // When: 스킵 정보 초기화 + useCase.clearSkipInfo() + + // Then: Repository의 clear가 호출됨 + #expect(mockSkipRepository.clearCallCount == 1) + } + + // MARK: - 에러 처리 테스트 + + /// 앱스토어 조회 실패 시 에러가 전파되는지 확인합니다 + @Test("에러 처리: 앱스토어 조회 실패") + func errorHandling() async throws { + // Given: 앱스토어 조회 시 에러 발생 + mockAppStoreRepository.mockError = AppStoreError.invalidResponse + + // When/Then: 에러가 전파됨 + await #expect(throws: AppStoreError.self) { + try await useCase.checkUpdate(currentVersion: Version(major: 1, minor: 0, patch: 0)) + } + } +} diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift new file mode 100644 index 00000000..220217d8 --- /dev/null +++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift @@ -0,0 +1,58 @@ +import Testing + +@testable import MLSAppFeatureInterface + +/// Version 엔티티의 생성, 비교, 파싱 기능을 테스트합니다 +@Suite("Version 엔티티 테스트") +struct VersionTests { + + // MARK: - 초기화 테스트 + + /// 버전을 major, minor, patch로 초기화하고 속성이 올바른지 확인합니다 + @Test("버전 초기화 및 문자열 변환") + func versionInitialization() { + let version = Version(major: 1, minor: 2, patch: 3) + + #expect(version.major == 1) + #expect(version.minor == 2) + #expect(version.patch == 3) + #expect(version.versionString == "1.2.3") + } + + /// 버전 문자열로부터 Version 객체를 생성하고 파싱이 올바른지 확인합니다 + @Test("버전 문자열 파싱") + func versionFromString() { + // 정상 케이스: "1.2.3" + #expect(Version(versionString: "1.2.3") == Version(major: 1, minor: 2, patch: 3)) + + // patch가 없는 경우: "1.2" → patch는 0으로 처리 + #expect(Version(versionString: "1.2") == Version(major: 1, minor: 2, patch: 0)) + + // 잘못된 형식 + #expect(Version(versionString: "invalid") == nil) + #expect(Version(versionString: "1") == nil) + } + + // MARK: - 비교 테스트 + + /// 버전 간 비교 연산자가 올바르게 동작하는지 확인합니다 + @Test("버전 비교 연산") + func versionComparison() { + let v1 = Version(major: 1, minor: 0, patch: 0) + let v2 = Version(major: 2, minor: 0, patch: 0) + let v3 = Version(major: 1, minor: 2, patch: 0) + let v4 = Version(major: 1, minor: 2, patch: 3) + + // major 버전 비교 + #expect(v1 < v2) + + // minor 버전 비교 + #expect(v1 < v3) + + // patch 버전 비교 + #expect(v3 < v4) + + // 동등 비교 + #expect(v1 == Version(major: 1, minor: 0, patch: 0)) + } +}