diff --git a/HMH_Tuist_iOS/HMH-Tuist-iOS.xcworkspace/xcshareddata/xcschemes/HMH-Tuist-iOS-Workspace.xcscheme b/HMH_Tuist_iOS/HMH-Tuist-iOS.xcworkspace/xcshareddata/xcschemes/HMH-Tuist-iOS-Workspace.xcscheme index 6ed3f320..28e3f59c 100644 --- a/HMH_Tuist_iOS/HMH-Tuist-iOS.xcworkspace/xcshareddata/xcschemes/HMH-Tuist-iOS-Workspace.xcscheme +++ b/HMH_Tuist_iOS/HMH-Tuist-iOS.xcworkspace/xcshareddata/xcschemes/HMH-Tuist-iOS-Workspace.xcscheme @@ -62,20 +62,6 @@ ReferencedContainer = "container:Projects/Features/ChallengeFeature/ChallengeFeature.xcodeproj"> - - - - + + + + - - - - - - - - - - - - - - - - + + + + ProjectDescription.Path { + .relativeToRoot("./XCConfig/\(target.rawValue).xcconfig") + } + + public static let configurations: [Configuration] = [ + .build(.dev), + .build(.qa), + .build(.prod) + ] +} + +public enum BuildTarget: String { + case dev = "DEV" + case qa = "QA" + case prod = "PROD" +} + +public extension Configuration { + static func build(_ target: BuildTarget) -> Self { + switch target { + case .dev: + return .debug( + name: "Development", + xcconfig: XCConfig.path(for: .dev) + ) + case .qa: + return .release( + name: "QA", + xcconfig: XCConfig.path(for: .qa) + ) + case .prod: + return .release( + name: "PROD", + xcconfig: XCConfig.path(for: .prod) + ) + } + } +} + +// +// Configurations.swift +// MyPlugin +// +// Created by 류희재 on 7/13/24. +// + +import Foundation +import ProjectDescription + +/// 빌드할 환경에 대한 설정 + +/// Target 분리 (The Modular Architecture 기반으로 분리했습니다) + + /// DEV : 실제 프로덕트 BaseURL을 사용하는 debug scheme /// TEST : 테스트 BaseURL을 사용하는 debug scheme /// QA : 테스트 BaseURL을 사용하는 release scheme @@ -23,38 +81,45 @@ import ProjectDescription /// 2) TEST, QA -> 테스트 BaseURL -public struct XCConfig { - private struct Path { - static var framework: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Framework.xcconfig") } - static var demo: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Demo.xcconfig") } - static var tests: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Tests.xcconfig") } - static func project(_ config: String) -> ProjectDescription.Path { .relativeToRoot("Configurations/Base/Projects/Project-\(config).xcconfig") } - } - - public static let framework: [Configuration] = [ - .debug(name: "Development", xcconfig: Path.framework), - .debug(name: "Test", xcconfig: Path.framework), - .release(name: "QA", xcconfig: Path.framework), - .release(name: "PROD", xcconfig: Path.framework), - ] - - public static let tests: [Configuration] = [ - .debug(name: "Development", xcconfig: Path.tests), - .debug(name: "Test", xcconfig: Path.tests), - .release(name: "QA", xcconfig: Path.tests), - .release(name: "PROD", xcconfig: Path.tests), - ] - public static let demo: [Configuration] = [ - .debug(name: "Development", xcconfig: Path.demo), - .debug(name: "Test", xcconfig: Path.demo), - .release(name: "QA", xcconfig: Path.demo), - .release(name: "PROD", xcconfig: Path.demo), - ] - public static let project: [Configuration] = [ - .debug(name: "Development", xcconfig: Path.project("Development")), - .debug(name: "Test", xcconfig: Path.project("Test")), - .release(name: "QA", xcconfig: Path.project("QA")), - .release(name: "PROD", xcconfig: Path.project("PROD")), - ] -} - +//public struct XCConfig { +// private struct Path { +// static var framework: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Framework.xcconfig") } +// static var demo: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Demo.xcconfig") } +// static var tests: ProjectDescription.Path { .relativeToRoot("Configurations/Targets/iOS-Tests.xcconfig") } +// static func project(_ config: String) -> ProjectDescription.Path { .relativeToRoot("Configurations/Base/Projects/Project-\(config).xcconfig") } +// } +// +// public static let framework: [Configuration] = [ +// .debug(name: "Development", xcconfig: .relativeToRoot("XCConfig/DEV.xcconfig")), +// .release(name: "PROD", xcconfig: .relativeToRoot("XCConfig/PROD.xcconfig")) +//// .debug(name: "Development", xcconfig: Path.framework), +//// .debug(name: "Test", xcconfig: Path.framework), +//// .release(name: "QA", xcconfig: Path.framework), +//// .release(name: "PROD", xcconfig: Path.framework), +// ] +// +// public static let tests: [Configuration] = [ +// .debug(name: "Development", xcconfig: .relativeToRoot("XCConfig/DEV.xcconfig")), +// .release(name: "PROD", xcconfig: .relativeToRoot("XCConfig/PROD.xcconfig")) +//// .debug(name: "Development", xcconfig: Path.tests), +////// .debug(name: "Test", xcconfig: Path.tests), +////// .release(name: "QA", xcconfig: Path.tests), +//// .release(name: "PROD", xcconfig: Path.tests), +// ] +// public static let demo: [Configuration] = [ +// .debug(name: "Development", xcconfig: .relativeToRoot("XCConfig/DEV.xcconfig")), +// .release(name: "PROD", xcconfig: .relativeToRoot("XCConfig/PROD.xcconfig")) +//// .debug(name: "Development", xcconfig: Path.demo), +////// .debug(name: "Test", xcconfig: Path.demo), +////// .release(name: "QA", xcconfig: Path.demo), +//// .release(name: "PROD", xcconfig: Path.demo), +// ] +// public static let project: [Configuration] = [ +// .debug(name: "Development", xcconfig: .relativeToRoot("XCConfig/DEV.xcconfig")), +// .release(name: "PROD", xcconfig: .relativeToRoot("XCConfig/PROD.xcconfig")) +//// .debug(name: "Development", xcconfig: Path.project("Development")), +////// .debug(name: "Test", xcconfig: Path.project("Test")), +////// .release(name: "QA", xcconfig: Path.project("QA")), +//// .release(name: "PROD", xcconfig: Path.project("PROD")), +// ] +//} diff --git a/HMH_Tuist_iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/SettingDictionary+.swift b/HMH_Tuist_iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/SettingDictionary+.swift index 3318a9e7..7dd53b79 100644 --- a/HMH_Tuist_iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/SettingDictionary+.swift +++ b/HMH_Tuist_iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/SettingDictionary+.swift @@ -18,6 +18,8 @@ public extension SettingsDictionary { ] ] + ///역할: Objective-C 카테고리 및 클래스를 정적으로 링크하도록 보장. + ///사용 사례: Objective-C 기반 라이브러리를 사용할 때 기본적으로 필요. static let baseSettings: Self = [ "OTHER_LDFLAGS" : [ "$(inherited)", @@ -25,48 +27,59 @@ public extension SettingsDictionary { ] ] + ///번들 ID를 설정합니다. func setProductBundleIdentifier(_ value: String = "com.iOS$(BUNDLE_ID_SUFFIX)") -> SettingsDictionary { merging(["PRODUCT_BUNDLE_IDENTIFIER": SettingValue(stringLiteral: value)]) } + ///앱 아이콘 이름을 설정합니다. func setAssetcatalogCompilerAppIconName(_ value: String = "AppIcon$(BUNDLE_ID_SUFFIX)") -> SettingsDictionary { merging(["ASSETCATALOG_COMPILER_APPICON_NAME": SettingValue(stringLiteral: value)]) } + ///활성화된 아키텍처만 빌드할지 설정 (ONLY_ACTIVE_ARCH). func setBuildActiveArchitectureOnly(_ value: Bool) -> SettingsDictionary { merging(["ONLY_ACTIVE_ARCH": SettingValue(stringLiteral: value ? "YES" : "NO")]) } + ///특정 SDK에서 제외할 아키텍처를 설정 (EXCLUDED_ARCHS). func setExcludedArchitectures(sdk: String = "iphonesimulator*", _ value: String = "arm64") -> SettingsDictionary { merging(["EXCLUDED_ARCHS[sdk=\(sdk)]": SettingValue(stringLiteral: value)]) } + ///Swift 활성 컴파일 조건을 설정 (SWIFT_ACTIVE_COMPILATION_CONDITIONS). func setSwiftActiveComplationConditions(_ value: String) -> SettingsDictionary { merging(["SWIFT_ACTIVE_COMPILATION_CONDITIONS": SettingValue(stringLiteral: value)]) } + ///사용자 경로를 항상 검색할지 설정 (ALWAYS_SEARCH_USER_PATHS). func setAlwaysSearchUserPath(_ value: String = "NO") -> SettingsDictionary { merging(["ALWAYS_SEARCH_USER_PATHS": SettingValue(stringLiteral: value)]) } + ///복사 과정에서 디버그 심볼 제거 여부를 설정 (COPY_PHASE_STRIP). func setStripDebugSymbolsDuringCopy(_ value: String = "NO") -> SettingsDictionary { merging(["COPY_PHASE_STRIP": SettingValue(stringLiteral: value)]) } + ///동적 라이브러리 기본 설치 경로 설정 (DYLIB_INSTALL_NAME_BASE). func setDynamicLibraryInstallNameBase(_ value: String = "@rpath") -> SettingsDictionary { merging(["DYLIB_INSTALL_NAME_BASE": SettingValue(stringLiteral: value)]) } + ///설치 대상 여부를 설정 (SKIP_INSTALL). func setSkipInstall(_ value: Bool = false) -> SettingsDictionary { merging(["SKIP_INSTALL": SettingValue(stringLiteral: value ? "YES" : "NO")]) } + ///코드 서명을 수동으로 설정합니다. func setCodeSignManual() -> SettingsDictionary { merging(["CODE_SIGN_STYLE": SettingValue(stringLiteral: "Manual")]) .merging(["DEVELOPMENT_TEAM": SettingValue(stringLiteral: "9K86FQHDLU")]) .merging(["CODE_SIGN_IDENTITY": SettingValue(stringLiteral: "$(CODE_SIGN_IDENTITY)")]) } + ///프로비저닝 프로파일 설정. func setProvisioning() -> SettingsDictionary { merging(["PROVISIONING_PROFILE_SPECIFIER": SettingValue(stringLiteral: "$(APP_PROVISIONING_PROFILE)")]) .merging(["PROVISIONING_PROFILE": SettingValue(stringLiteral: "$(APP_PROVISIONING_PROFILE)")]) diff --git a/HMH_Tuist_iOS/Projects/App/Derived/InfoPlists/HMH-iOS-Info.plist b/HMH_Tuist_iOS/Projects/App/Derived/InfoPlists/HMH-iOS-Info.plist index a3d5de43..a58e1a02 100644 --- a/HMH_Tuist_iOS/Projects/App/Derived/InfoPlists/HMH-iOS-Info.plist +++ b/HMH_Tuist_iOS/Projects/App/Derived/InfoPlists/HMH-iOS-Info.plist @@ -2,75 +2,87 @@ - BASE_URL - $(BASE_URL) - BGTaskSchedulerPermittedIdentifiers - - com.HMH.dailyTask - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - kakao$(KAKAO_API_KEY) - - - - CFBundleVersion - 1 - KAKAO_API_KEY - $(KAKAO_API_KEY) - LSApplicationQueriesSchemes - - kakaokompassauth - kakaolink - - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - UIAppFonts - - Pretendard-Regular.otf - Pretendard-SemiBold.otf - Pretendard-Medium.otf - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - + BASE_URL + $(BASE_URL) + BGTaskSchedulerPermittedIdentifiers + + com.HMH.dailyTask + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + kakao$(KAKAO_API_KEY) + + + + CFBundleVersion + 1 + KAKAO_API_KEY + $(KAKAO_API_KEY) + LSApplicationQueriesSchemes + + kakaokompassauth + kakaolink + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIAppFonts + + Pretendard-Regular.otf + Pretendard-SemiBold.otf + Pretendard-Medium.otf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/HMH_Tuist_iOS/Projects/Core/Sources/Extension/Date+.swift b/HMH_Tuist_iOS/Projects/Core/Sources/Extension/Date+.swift index eac0649e..d0486e5b 100644 --- a/HMH_Tuist_iOS/Projects/Core/Sources/Extension/Date+.swift +++ b/HMH_Tuist_iOS/Projects/Core/Sources/Extension/Date+.swift @@ -21,4 +21,20 @@ public extension String { formatter.dateFormat = format return formatter.date(from: self) } + + + func challengeHeaderFormattd() -> String? { + let inputDateFormatter = DateFormatter() + inputDateFormatter.dateFormat = "yyyy-MM-dd" + guard let date = inputDateFormatter.date(from: self) else { + return nil + } + + let outputDateFormatter = DateFormatter() + outputDateFormatter.dateFormat = "M월 d일" + let formattedDateString = outputDateFormatter.string(from: date) + + return formattedDateString + } + } diff --git a/HMH_Tuist_iOS/Projects/Features/HomeFeature/Derived/InfoPlists/HomeFeatureInterface-Info.plist b/HMH_Tuist_iOS/Projects/Data/Derived/InfoPlists/DataTests-Info.plist similarity index 96% rename from HMH_Tuist_iOS/Projects/Features/HomeFeature/Derived/InfoPlists/HomeFeatureInterface-Info.plist rename to HMH_Tuist_iOS/Projects/Data/Derived/InfoPlists/DataTests-Info.plist index 323e5ecf..6c40a6cd 100644 --- a/HMH_Tuist_iOS/Projects/Features/HomeFeature/Derived/InfoPlists/HomeFeatureInterface-Info.plist +++ b/HMH_Tuist_iOS/Projects/Data/Derived/InfoPlists/DataTests-Info.plist @@ -13,7 +13,7 @@ CFBundleName $(PRODUCT_NAME) CFBundlePackageType - FMWK + BNDL CFBundleShortVersionString 1.0 CFBundleVersion diff --git a/HMH_Tuist_iOS/Projects/Data/Project.swift b/HMH_Tuist_iOS/Projects/Data/Project.swift index b842c871..e59430ca 100644 --- a/HMH_Tuist_iOS/Projects/Data/Project.swift +++ b/HMH_Tuist_iOS/Projects/Data/Project.swift @@ -11,7 +11,7 @@ import DependencyPlugin let project = Project.makeModule( name: "Data", - targets: [.staticFramework, .demo], + targets: [.staticFramework, .demo, .unitTest], internalDependencies: [ .domain, .Modules.networks diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/ChallengeSuccessInfoMapper.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/ChallengeSuccessInfoMapper.swift new file mode 100644 index 00000000..4fc8636b --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/ChallengeSuccessInfoMapper.swift @@ -0,0 +1,24 @@ +// +// DailyChallengeTransform.swift +// Data +// +// Created by 류희재 on 10/29/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension FinishedDailyChallenge { + func toEntity() -> ChallengeSuccessInfo { + return .init(challengeDate: challengeDate, isSuccess: isSuccess) + } +} + +extension ChallengeSuccessInfo { + func toDTO() -> FinishedDailyChallenge { + return .init(challengeDate: challengeDate, isSuccess: isSuccess) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/DailyChallengeInfoMapper.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/DailyChallengeInfoMapper.swift index abd3b747..385207e9 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/DailyChallengeInfoMapper.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/Challenge/DailyChallengeInfoMapper.swift @@ -1,18 +1,20 @@ // -// DailyChallengeTransform.swift +// HomeChallengeDetailMapper.swift // Data // -// Created by 류희재 on 10/29/24. +// Created by 류희재 on 11/5/24. // Copyright © 2024 HMH-iOS. All rights reserved. // -import Foundation - import Domain import Networks -extension FinishedDailyChallenge { - func toEntity() -> DailyChallengeInfo { - return .init(challengeDate: challengeDate, isSuccess: isSuccess) +extension DailyChallengeResult { + public func toEntity() -> DailyChallengeInfo { + .init( + status: status, + goalTime: goalTime, + apps: apps.map { $0.toEntity() } + ) } } diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/ErrorMapper.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/ErrorMapper.swift index bc1a4b2b..10f71bd0 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/ErrorMapper.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Mapper/ErrorMapper.swift @@ -15,11 +15,20 @@ import Networks extension Publisher where Failure == HMHNetworkError { func mapToDomainError(to errorType: T.Type) -> AnyPublisher { self.mapError { networkError in - if case let .invalidResponse(responseError) = networkError, - let errorMessage = responseError.invalidStatusCodeMessage() { + switch networkError { + case let .invalidResponse(responseError): + if let errorMessage = responseError.invalidStatusCodeMessage() { + return T.error(with: errorMessage) + } else { + return T.error(with: "네트워크 오류입니다") + } + + case let .oautheticationError(authrizationError): + let errorMessage = authrizationError.authrizationErrorMessage() return T.error(with: errorMessage) - } else { - return T.error(with: "알 수 없는 오류") + + default: + return T.error(with: "네트워크 오류입니다") } } .eraseToAnyPublisher() diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/AuthRepository.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/AuthRepository.swift index 5775d718..ed44d53d 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/AuthRepository.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/AuthRepository.swift @@ -17,16 +17,16 @@ public struct AuthRepository: AuthRepositoryType { private let authService: AuthServiceType private let oauthServiceFactory: OAuthServiceFactoryType - init(authService: AuthServiceType, oauthServiceFactory: OAuthServiceFactoryType) { + public init(authService: AuthServiceType, oauthServiceFactory: OAuthServiceFactoryType) { self.authService = authService self.oauthServiceFactory = oauthServiceFactory } - public func authorize(_ serviceType: OAuthProviderType) -> AnyPublisher { + public func authorize(_ serviceType: OAuthProviderType) -> AnyPublisher { let oauthService = oauthServiceFactory.makeOAuthService(for: serviceType) return oauthService.authorize() .map { $0 } - .mapToGeneralError() + .mapToDomainError(to: AuthError.self) } public func signUp(socialPlatform: String, name: String, averageUseTime: String, problem: [String], challengeInfo: ChallengeInfo) -> AnyPublisher { diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/ChallengeRepository.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/ChallengeRepository.swift index b9900216..2979a64f 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/ChallengeRepository.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/ChallengeRepository.swift @@ -12,60 +12,61 @@ import Combine import Domain import Networks -struct ChallengeRepository: ChallengeRepositoryType { +public struct ChallengeRepository: ChallengeRepositoryType { private let service: ChallengeServiceType - init(service: ChallengeServiceType) { + public init(service: ChallengeServiceType) { self.service = service } - func getdailyChallenge() -> AnyPublisher { - service.getdailyChallenge() + public func getdailyChallenge() -> AnyPublisher { + service.getDailyChallenge() .map { $0.toEntity() } .mapToDomainError(to: ChallengeError.self) } - func getSuccesChallenge() -> AnyPublisher<[String], ChallengeError> { - service.getSuccesChallenge() + public func postSuccesChallenge(sucessInfo: [ChallengeSuccessInfo]) -> AnyPublisher<[String], ChallengeError> { + let request = ChallengeSuccessRequest(finishedDailyChallenges: sucessInfo.map { $0.toDTO() }) + return service.postSuccesChallenge(request: request) .map { $0.statuses } .mapToDomainError(to: ChallengeError.self) } - func createChallenge(period: Int, goalTime: Int) -> AnyPublisher { + public func createChallenge(period: Int, goalTime: Int) -> AnyPublisher { let request = CreateChallengeRequest(period: period, goalTime: goalTime) return service.createChallenge(request: request) .map { _ in () } .mapToDomainError(to: ChallengeError.self) } - func getLockChallenge() -> AnyPublisher { + public func getLockChallenge() -> AnyPublisher { return service.getLockChallenge() .map { $0.isLockToday } .mapToDomainError(to: ChallengeError.self) } - func postLockChallenge() -> AnyPublisher { + public func postLockChallenge() -> AnyPublisher { return service.postLockChallenge() .map { _ in () } .mapToDomainError(to: ChallengeError.self) } - func deleteApp(appCode: String) -> AnyPublisher { + public func deleteApp(appCode: String) -> AnyPublisher { let request = DeleteAppRequest(appCode: appCode) return service.deleteApp(request: request) .map { _ in () } .mapToDomainError(to: ChallengeError.self) } - func addApp(apps: [AppInfo]) -> AnyPublisher { + public func addApp(apps: [AppInfo]) -> AnyPublisher { let request = AddAppRequest(apps: apps.map { $0.toDTO() }) return service.addApp(request: request) .map { _ in () } .mapToDomainError(to: ChallengeError.self) } - func getChallenge() -> AnyPublisher { + public func getChallenge() -> AnyPublisher { service.getChallenge() .map { $0.toEntity() } .mapToDomainError(to: ChallengeError.self) diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/PointRepository.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/PointRepository.swift index 66d6c6c5..8968b112 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/PointRepository.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/PointRepository.swift @@ -15,7 +15,7 @@ import Networks public struct PointRepository: PointRepositoryType { private let service: PointServiceType - init(service: PointServiceType) { + public init(service: PointServiceType) { self.service = service } diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/UserRepository.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/UserRepository.swift index e3ae59c2..ab8df1ca 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Repository/UserRepository.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Repository/UserRepository.swift @@ -15,30 +15,32 @@ import Networks public struct UserRepository: UserRepositoryType { private let service: UserServiceType - init(service: UserServiceType) { + public init(service: UserServiceType) { self.service = service } - public func logout() -> AnyPublisher { + public func logout() -> AnyPublisher { service.logout() - .asVoidWithGeneralError() + .map { _ in () } + .mapToDomainError(to: UserError.self) } - public func deleteAccount() -> AnyPublisher { + public func deleteAccount() -> AnyPublisher { service.deleteAccount() - .asVoidWithGeneralError() + .map { _ in () } + .mapToDomainError(to: UserError.self) } - public func getUserData() -> AnyPublisher { + public func getUserData() -> AnyPublisher { service.getUserData() .map { $0.toEntity() } - .mapToGeneralError() + .mapToDomainError(to: UserError.self) } - public func getCurrentPoint() -> AnyPublisher { + public func getCurrentPoint() -> AnyPublisher { service.getCurrentPoint() .map {$0.point} - .mapToGeneralError() + .mapToDomainError(to: UserError.self) } } diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Test/DataTestApp.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Test/DataTestApp.swift index a458c94c..433fb0ff 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Test/DataTestApp.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Test/DataTestApp.swift @@ -16,16 +16,17 @@ import KakaoSDKAuth @main struct DataTestApp: App { - let kakaoAPIKey = Bundle.main.infoDictionary?["KAKAO_API_KEY"] as! String + let kakaoAPIKey = Networks.Config.appKey init() { KakaoSDK.initSDK(appKey: kakaoAPIKey) + print(Networks.Config.baseURL) } var body: some Scene { WindowGroup { NetworkTestUI( repository: AuthRepository( - authService: AuthService(requestHandler: RequestHandler()), + authService: AuthService(), oauthServiceFactory: OAuthServiceFactory() ) ) diff --git a/HMH_Tuist_iOS/Projects/Data/Sources/Test/NetworkTestUI.swift b/HMH_Tuist_iOS/Projects/Data/Sources/Test/NetworkTestUI.swift index 08180a3a..4a4a6020 100644 --- a/HMH_Tuist_iOS/Projects/Data/Sources/Test/NetworkTestUI.swift +++ b/HMH_Tuist_iOS/Projects/Data/Sources/Test/NetworkTestUI.swift @@ -60,7 +60,7 @@ struct NetworkTestUI: View { #Preview { return NetworkTestUI( repository: AuthRepository( - authService: AuthService(requestHandler: RequestHandler()), + authService: AuthService(), oauthServiceFactory: OAuthServiceFactory() ) ) diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/AuthorizeTest/AuthorizeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/AuthorizeTest/AuthorizeTest.swift new file mode 100644 index 00000000..eff467bd --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/AuthorizeTest/AuthorizeTest.swift @@ -0,0 +1,128 @@ +// +// AutorizeTest.swift +// DataTests +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain +import Data + +/// 회원가입 API 데이터 변환 테스트 +extension AuthRepositoryTests { + func test_카카오인증처리_정상적인변환() { + let expectedData = "Access토큰" + let resultData = "Access토큰" + let expectation = XCTestExpectation(description: "카카오 인증처리 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + let kakaoService = MockOAuthKakaoService() + kakaoService.authorizeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + let mockFactory = MockOAuthServiceFactory() + mockFactory.service = kakaoService + + sut = AuthRepository(authService: mockAuthService, oauthServiceFactory: mockFactory) + + sut.authorize(.kakao) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("카카오 인증처리 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + + + wait(for: [expectation], timeout: 1.0) + } + + func test_카카오인증처리_에러가발생시_에러반환() { + + let message = "카카오 로그인 시도 중 생긴 oauth 오류입니다" + let networkError = HMHNetworkError.oautheticationError(.kakaoLoginError) + + let kakaoService = MockOAuthKakaoService() + kakaoService.authorizeResult = Fail(error: networkError) + .eraseToAnyPublisher() + let mockFactory = MockOAuthServiceFactory() + mockFactory.service = kakaoService + + sut = AuthRepository(authService: mockAuthService, oauthServiceFactory: mockFactory) + + // When + let expectedError = AuthError.kakaoAuthrizeError + let expectation = XCTestExpectation(description: message) + + sut.authorize(.kakao) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_애플인증처리_정상적인변환() { + let expectedData = "Access토큰" + let resultData = "Access토큰" + let expectation = XCTestExpectation(description: "애플 인증처리 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + let appleService = MockOAuthAppleService() + appleService.authorizeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + let mockFactory = MockOAuthServiceFactory() + mockFactory.service = appleService + + sut = AuthRepository(authService: mockAuthService, oauthServiceFactory: mockFactory) + + sut.authorize(.apple) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("애플 인증처리 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + + + wait(for: [expectation], timeout: 1.0) + } + + func test_애플인증처리_에러가발생시_에러반환() { + + let message = "애플 로그인 시도 중 생긴 oauth 오류입니다" + let networkError = HMHNetworkError.oautheticationError(.appleLoginError) + + let appleService = MockOAuthAppleService() + appleService.authorizeResult = Fail(error: networkError) + .eraseToAnyPublisher() + let mockFactory = MockOAuthServiceFactory() + mockFactory.service = appleService + + sut = AuthRepository(authService: mockAuthService, oauthServiceFactory: mockFactory) + + // When + let expectedError = AuthError.appleAuthrizeError + let expectation = XCTestExpectation(description: message) + + sut.authorize(.apple) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthMockData.swift new file mode 100644 index 00000000..4aea4386 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthMockData.swift @@ -0,0 +1,36 @@ +// +// SocialLoginMockData.swift +// Data +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension Auth { + static public var expectedData: [Auth] { + return [ + .init(userId: 1, accessToken: "123123", refreshToken: "456456"), + .init(userId: 2, accessToken: "", refreshToken: "456456"), + .init(userId: 3, accessToken: "123123", refreshToken: ""), + + ] + } +} + +extension AuthResult { + static public var resultData: [AuthResult] { + return [ + .init(userId: 1, token: TokenResult(accessToken: "123123", refreshToken: "456456")), + .init(userId: 2, token: TokenResult(accessToken: "", refreshToken: "456456")), + .init(userId: 3, token: TokenResult(accessToken: "123123", refreshToken: "")), + ] + } +} + + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthRepositoryTests.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthRepositoryTests.swift new file mode 100644 index 00000000..be138c39 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/AuthRepositoryTests.swift @@ -0,0 +1,62 @@ +// +// AuthRepositoryTests.swift +// DataTests +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain +import Data + +final class AuthRepositoryTests: XCTestCase { + + var sut: AuthRepositoryType! + var mockAuthService: MockAuthService! + var mockOAuthServiceFactory: MockOAuthServiceFactory! + var cancelBag: CancelBag! + + override func setUpWithError() throws { + cancelBag = CancelBag() + mockAuthService = MockAuthService() + mockOAuthServiceFactory = MockOAuthServiceFactory() + sut = AuthRepository(authService: mockAuthService, oauthServiceFactory: mockOAuthServiceFactory) + } + + override func tearDown() { + cancelBag = nil + mockAuthService = nil + mockOAuthServiceFactory = nil + sut = nil + } +} + +extension AuthRepositoryTests { + public func handleCompletion(expectedError: T? = nil, expectation: XCTestExpectation) -> (Subscribers.Completion) -> Void { + return { completion in + if case .failure(let error) = completion { + XCTAssertEqual(error, expectedError, "Expected error \(String(describing: expectedError)), but got \(error)") + expectation.fulfill() + } else { + XCTFail("Expected failure with error \(String(describing: expectedError)), but received success") + } + } + } + + public func valueHandler(expectation: XCTestExpectation, expectedValue: T) -> (T) -> Void { + return { receivedValue in + XCTAssertEqual(receivedValue, expectedValue, "Received value \(receivedValue) does not match expected data \(expectedValue)") + expectation.fulfill() + } + } + + public func failureExpectedValueHandler() -> (T) -> Void { + return { _ in XCTFail("Expected failure, but got success") } + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockAuthService.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockAuthService.swift new file mode 100644 index 00000000..47b1115b --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockAuthService.swift @@ -0,0 +1,29 @@ +// +// MockAuthService.swift +// Data +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Domain +import Networks + +final public class MockAuthService: AuthServiceType { + + public init() {} + + public var signUpResult:AnyPublisher! + public var socialLoginResult:AnyPublisher! + + public func signUp(request: SignUpRequest) -> AnyPublisher { + return signUpResult + } + + public func socialLogin(request: SocialLoginRequest) -> AnyPublisher { + return socialLoginResult + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockOAuthServiceFactory.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockOAuthServiceFactory.swift new file mode 100644 index 00000000..457c2925 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/Base/MockOAuthServiceFactory.swift @@ -0,0 +1,46 @@ +// +// MockOAuthServiceFactory.swift +// Data +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Domain +import Networks +import Data + +public class MockOAuthServiceFactory: OAuthServiceFactoryType { + public var service: OAuthServiceType! + + public init() {} + + public func makeOAuthService(for providerType: OAuthProviderType) -> OAuthServiceType { + return service + } +} + +final public class MockOAuthKakaoService: OAuthServiceType { + + public init() {} + + public var authorizeResult:AnyPublisher! + + public func authorize() -> AnyPublisher { + return authorizeResult + } +} + +final public class MockOAuthAppleService: OAuthServiceType { + + public init() {} + + public var authorizeResult:AnyPublisher! + + public func authorize() -> AnyPublisher { + return authorizeResult + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SignUpTest/SignUpTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SignUpTest/SignUpTest.swift new file mode 100644 index 00000000..bf238837 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SignUpTest/SignUpTest.swift @@ -0,0 +1,110 @@ +// +// SignUpTest.swift +// DataTests +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 회원가입 API 데이터 변환 테스트 +extension AuthRepositoryTests { + func test_회원가입_정상적인변환() { + let testCases = Array(zip(Auth.expectedData, AuthResult.resultData)) + let expectation = XCTestExpectation(description: "회원가입 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockAuthService.signUpResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.signUp(socialPlatform: "KAKAO", name: "류희재", averageUseTime: "1~4", problem: ["아아아아"], challengeInfo: .init(period: 1, goalTime: 1, apps: [.stub])) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("회원가입 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_회원가입_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockAuthService.signUpResult = Fail(error: expected).eraseToAnyPublisher() + + sut.signUp(socialPlatform: "KAKAO", name: "류희재", averageUseTime: "1~4", problem: ["아아아아"], challengeInfo: .init(period: 1, goalTime: 1, apps: [.stub])) + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_회원가입_회원가입정보가없을때_에러반환() { + // Given + let message = "온보딩 정보 또는 챌린지 정보 없음" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockAuthService.signUpResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = AuthError.noSignUpInfo + let expectation = XCTestExpectation(description: message) + + sut.signUp(socialPlatform: "KAKAO", name: "류희재", averageUseTime: "1~4", problem: ["아아아아"], challengeInfo: .init(period: 1, goalTime: 1, apps: [.stub])) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_회원가입_이미회원가입을했을때_에러반환() { + // Given + let message = "이미 회원가입된 유저입니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockAuthService.signUpResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = AuthError.alreadyRegisteredUser + let expectation = XCTestExpectation(description: message) + + sut.signUp(socialPlatform: "KAKAO", name: "류희재", averageUseTime: "1~4", problem: ["아아아아"], challengeInfo: .init(period: 1, goalTime: 1, apps: [.stub])) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SocialLoginTest/SocialLoginTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SocialLoginTest/SocialLoginTest.swift new file mode 100644 index 00000000..f894c25a --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/AuthRepositoryTests/SocialLoginTest/SocialLoginTest.swift @@ -0,0 +1,86 @@ +// +// SocialLoginTest.swift +// DataTests +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 소셜로그인 API 데이터 변환 테스트 +extension AuthRepositoryTests { + func test_소셜로그인_정상적인변환() { + let testCases = Array(zip(Auth.expectedData, AuthResult.resultData)) + let expectation = XCTestExpectation(description: "소셜로그인 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockAuthService.socialLoginResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.socialLogin(socialPlatform: "KAKAO") + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("소셜로그인 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_소셜로그인_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockAuthService.socialLoginResult = Fail(error: expected).eraseToAnyPublisher() + + sut.socialLogin(socialPlatform: "KAKAO") + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_회원가입_회원이아닌경우_에러반환() { + // Given + let message = "회원가입된 유저가 아닙니다. 회원가입을 진행해주세요." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 403, message: message)) + + mockAuthService.signUpResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = AuthError.unregisteredUser + let expectation = XCTestExpectation(description: message) + + sut.signUp(socialPlatform: "KAKAO", name: "류희재", averageUseTime: "1~4", problem: ["아아아아"], challengeInfo: .init(period: 1, goalTime: 1, apps: [.stub])) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/AddAppTest/AddAppTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/AddAppTest/AddAppTest.swift new file mode 100644 index 00000000..ae3a0b7e --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/AddAppTest/AddAppTest.swift @@ -0,0 +1,62 @@ +// +// AddAppTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 스크린타임 설정할 앱 추가 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_스크린타임설정할앱추가_정상적인변환() { + + let expectation = XCTestExpectation(description: "스크린타임 설정할 앱 추가 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.addAppResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.addApp(apps: [.stub]) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("스크린타임 설정할 앱 추가 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_스크린타임설정할앱추가_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.addAppResult = Fail(error: expected).eraseToAnyPublisher() + + sut.addApp(apps: [.stub]) + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/ChallegeRepositoryTests.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/ChallegeRepositoryTests.swift new file mode 100644 index 00000000..c0ab08cb --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/ChallegeRepositoryTests.swift @@ -0,0 +1,59 @@ +// +// ChallegeRepositoryTests.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain +import Data + +final class ChallegeRepositoryTests: XCTestCase { + + var sut: ChallengeRepositoryType! + var mockService: MockChallengeService! + var cancelBag: CancelBag! + + override func setUpWithError() throws { + cancelBag = CancelBag() + mockService = MockChallengeService() + sut = ChallengeRepository(service: mockService) + } + + override func tearDown() { + cancelBag = nil + mockService = nil + sut = nil + } +} + +extension ChallegeRepositoryTests { + public func handleCompletion(expectedError: T? = nil, expectation: XCTestExpectation) -> (Subscribers.Completion) -> Void { + return { completion in + if case .failure(let error) = completion { + XCTAssertEqual(error, expectedError, "Expected error \(String(describing: expectedError)), but got \(error)") + expectation.fulfill() + } else { + XCTFail("Expected failure with error \(String(describing: expectedError)), but received success") + } + } + } + + public func valueHandler(expectation: XCTestExpectation, expectedValue: T) -> (T) -> Void { + return { receivedValue in + XCTAssertEqual(receivedValue, expectedValue, "Received value \(receivedValue) does not match expected data \(expectedValue)") + expectation.fulfill() + } + } + + public func failureExpectedValueHandler() -> (T) -> Void { + return { _ in XCTFail("Expected failure, but got success") } + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/MockChallengeService.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/MockChallengeService.swift new file mode 100644 index 00000000..7b008e27 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/Base/MockChallengeService.swift @@ -0,0 +1,61 @@ +// +// MockChallengeService.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Domain +import Networks + +final public class MockChallengeService: ChallengeServiceType { + + public init() {} + + public var getDailyChallengeResult:AnyPublisher! + public var getSuccesChallengeResult:AnyPublisher! + public var createChallengeResult:AnyPublisher! + public var getLockChallengeResult:AnyPublisher! + public var postLockChallengeResult:AnyPublisher! + public var deleteAppResult:AnyPublisher! + public var addAppResult:AnyPublisher! + public var getChallengeResult:AnyPublisher! + + + public func getDailyChallenge() -> AnyPublisher { + return getDailyChallengeResult + } + + public func postSuccesChallenge() -> AnyPublisher { + return getSuccesChallengeResult + } + + public func createChallenge(request: CreateChallengeRequest) -> AnyPublisher { + return createChallengeResult + } + + public func getLockChallenge() -> AnyPublisher { + return getLockChallengeResult + } + + public func postLockChallenge() -> AnyPublisher { + return postLockChallengeResult + } + + public func deleteApp(request: DeleteAppRequest) -> AnyPublisher { + return deleteAppResult + } + + public func addApp(request: AddAppRequest) -> AnyPublisher { + return addAppResult + } + + public func getChallenge() -> AnyPublisher { + return getChallengeResult + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/CreateChallengeTest/CreateChallengeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/CreateChallengeTest/CreateChallengeTest.swift new file mode 100644 index 00000000..5075251f --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/CreateChallengeTest/CreateChallengeTest.swift @@ -0,0 +1,157 @@ +// +// CreateChallengeTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 챌린지 생성 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_챌린지생성_정상적인변환() { + + let expectation = XCTestExpectation(description: "챌린지 생성 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.createChallengeResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.createChallenge(period: 7, goalTime: 200000) + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("챌린지 생성 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_챌린지생성_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.createChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.createChallenge(period: 7, goalTime: 200000) + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_챌린지생성_챌린지기간이nil일때_에러반환() { + // Given + let message = "챌린지 기간은 null일 수 없습니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockService.createChallengeResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = ChallengeError.challengePeriodIsNil + let expectation = XCTestExpectation(description: message) + + sut.createChallenge(period: 7, goalTime: 200000) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_챌린지생성_챌린지기간이유효하지않을때_에러반환() { + // Given + let message = "유효한 숫자의 챌린지 기간을 입력해주세요." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockService.createChallengeResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = ChallengeError.invalidChallengePeriod + let expectation = XCTestExpectation(description: message) + + sut.createChallenge(period: 7, goalTime: 200000) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_챌린지생성_목표시간이nil일경우_에러반환() { + // Given + let message = "목표시간은 null일 수 없습니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockService.createChallengeResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = ChallengeError.goalTimeIsNil + let expectation = XCTestExpectation(description: message) + + sut.createChallenge(period: 7, goalTime: 200000) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_챌린지생성_목표시간이이유효하지않을때_에러반환() { + // Given + let message = "유효한 숫자의 목표 시간을 입력해주세요." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockService.createChallengeResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = ChallengeError.invalidGoalTime + let expectation = XCTestExpectation(description: message) + + sut.createChallenge(period: 7, goalTime: 200000) + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/DeleteAppTest/DeleteAppTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/DeleteAppTest/DeleteAppTest.swift new file mode 100644 index 00000000..925b44f3 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/DeleteAppTest/DeleteAppTest.swift @@ -0,0 +1,61 @@ +// +// DeleteAppTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 스크린타임 설정한 앱 삭제 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_스크린타임설정한앱삭제_정상적인변환() { + + let expectation = XCTestExpectation(description: "스크린타임 설정한 앱 삭제 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.deleteAppResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.deleteApp(appCode: "10000") + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("스크린타임 설정한 앱 삭제 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_스크린타임설정한앱삭제_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.deleteAppResult = Fail(error: expected).eraseToAnyPublisher() + + sut.deleteApp(appCode: "100000") + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeMockData.swift new file mode 100644 index 00000000..ada9c663 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeMockData.swift @@ -0,0 +1,62 @@ +// +// GetChallengeMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Networks +import Domain + +extension ChallengeDetail { + static public var expectedData: ChallengeDetail { + .init( + statuses: [ + "UNEARNED", + "UNEARNED", + "NONE", + "NONE", + "NONE", + "NONE", + "NONE" + ], + todayIndex: 2, + startDate: "2024-05-17", + challengeInfo: ChallengeInfo( + period: 7, + goalTime: 7200000, + apps: [ + .init(appCode: "#292043", goalTime: 12312420), + .init(appCode: "#693043", goalTime: 12312420) + ] + ) + ) + } +} + +extension GetChallengeResult { + static public var resultData: GetChallengeResult { + return .init( + period: 7, + statuses: [ + "UNEARNED", + "UNEARNED", + "NONE", + "NONE", + "NONE", + "NONE", + "NONE" + ], + todayIndex: 2, + startDate: "2024-05-17", + goalTime: 7200000, + apps: [ + .init(appCode: "#292043", goalTime: 12312420), + .init(appCode: "#693043", goalTime: 12312420) + ] + ) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeTest.swift new file mode 100644 index 00000000..b670a90f --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetChallengeTest/GetChallengeTest.swift @@ -0,0 +1,62 @@ +// +// GetChallengeTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 달성현황 정보 불러오기 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_달성현황정보불러오기_정상적인변환() { + let expectedData = ChallengeDetail.expectedData + let resultData = GetChallengeResult.resultData + let expectation = XCTestExpectation(description: "달성현황 정보 불러오기 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + + mockService.getChallengeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getChallenge() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("달성현황 정보 불러오기 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + + + wait(for: [expectation], timeout: 1.0) + } + + func test_달성현황정보불러오기_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.getChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.getChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeMockData.swift new file mode 100644 index 00000000..77975970 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeMockData.swift @@ -0,0 +1,32 @@ +// +// GetDailyChallengeMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension DailyChallengeInfo { + static public var expectedData: DailyChallengeInfo { + return .init( + status: "NONE", + goalTime: 7200000, + apps: [.init(appCode: "#292043", goalTime: 3200000)] + ) + } +} + +extension DailyChallengeResult { + static public var resultData: DailyChallengeResult { + return .init( + status: "NONE", + goalTime: 7200000, + apps: [.init(appCode: "#292043", goalTime: 3200000)] + ) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeTest.swift new file mode 100644 index 00000000..de878645 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetDailyChallengeTest/GetDailyChallengeTest.swift @@ -0,0 +1,88 @@ +// +// ChallegeRepositoryTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 홈 이용시간 통계 불러오기 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_홈이용시간통계불러오기_정상적인변환() { + let expectedData = DailyChallengeInfo.expectedData + let resultData = DailyChallengeResult.resultData + let expectation = XCTestExpectation(description: "홈 이용시간 통계 불러오기 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + + mockService.getDailyChallengeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getdailyChallenge() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("홈 이용시간 통계 불러오기 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + + + wait(for: [expectation], timeout: 1.0) + } + + func test_홈이용시간통계불러오기_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.getDailyChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.getdailyChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_홈이용시간통계불러오기_챌린지를찾을수없는경우_에러반환() { + // Given + let message = "챌린지를 찾을 수 없습니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.getDailyChallengeResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = ChallengeError.challengeNotFound + let expectation = XCTestExpectation(description: "챌린지를 찾을 수 없습니다.") + + sut.getdailyChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeMockData.swift new file mode 100644 index 00000000..8af505bd --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeMockData.swift @@ -0,0 +1,27 @@ +// +// GetLockChallengeMockData.swift +// Data +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension GetLockResult { + static public var expectedData: [Bool] { + return [true, false] + } +} + +extension GetLockResult { + static public var resultData: [GetLockResult] { + return [ + .init(isLockToday: true), + .init(isLockToday: false) + ] + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeTest.swift new file mode 100644 index 00000000..6f4d6e57 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetLockChallengeTest/GetLockChallengeTest.swift @@ -0,0 +1,62 @@ +// +// GetLockChallengeTest.swift +// DataTests +// +// Created by 류희재 on 11/6/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 당일 잠금 여부 확인 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_당일잠금여부확인_정상적인변환() { + let testCases = Array(zip(GetLockResult.expectedData, GetLockResult.resultData)) + + let expectation = XCTestExpectation(description: "당일 잠금 여부 확인 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + expectation.expectedFulfillmentCount = testCases.count + for (expectedData, resultData) in testCases { + mockService.getLockChallengeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getLockChallenge() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("챌린지 성공 여부 리스트 전송 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_당일잠금여부확인_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.postLockChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.postLockChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeMockData.swift new file mode 100644 index 00000000..9b5796c3 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeMockData.swift @@ -0,0 +1,34 @@ +// +// GetSuccesChallengeMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension ChallengeSuccessResult { + static public var expectedData: [[String]] { + return [ + ["안녕", "안녕", "안녕", "안녕", "안녕", "안녕"], + ["","","","",""], + ["asdfasdfasdfasdf","asdfasdfasdfasdf","asdfasdfasdfasdf"] + ] + } +} + +extension ChallengeSuccessResult { + static public var resultData: [ChallengeSuccessResult] { + return [ + .init(statuses: ["안녕", "안녕", "안녕", "안녕", "안녕", "안녕"]), + .init(statuses: ["","","","",""]), + .init(statuses: ["asdfasdfasdfasdf","asdfasdfasdfasdf","asdfasdfasdfasdf"]) + ] + } +} + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeTest.swift new file mode 100644 index 00000000..06fb908c --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/GetSuccesChallengeTest/GetSuccesChallengeTest.swift @@ -0,0 +1,62 @@ +// +// GetSuccesChallengeTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 챌린지 성공 여부 리스트 전송 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_챌린지성공여부리스트전송_정상적인변환() { + let testCases = Array(zip(ChallengeSuccessResult.expectedData, ChallengeSuccessResult.resultData)) + let expectation = XCTestExpectation(description: "챌린지 성공 여부 리스트 전송 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getSuccesChallengeResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.postSuccesChallenge() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("챌린지 성공 여부 리스트 전송 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_챌린지성공여부리스트전송_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.getSuccesChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.postSuccesChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/PostLockChallengeTest/PostLockChallgeTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/PostLockChallengeTest/PostLockChallgeTest.swift new file mode 100644 index 00000000..d07c88f5 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/ChallengeRepositoryTests/PostLockChallengeTest/PostLockChallgeTest.swift @@ -0,0 +1,61 @@ +// +// postLockChallgeTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 당일 잠금 여부 전송 API 데이터 변환 테스트 +extension ChallegeRepositoryTests { + func test_당일잠금여부전송_정상적인변환() { + + let expectation = XCTestExpectation(description: "당일 잠금 여부 전송 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.postLockChallengeResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.postLockChallenge() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("당일 잠금 여부 전송 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_당일잠금여부전송_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.postLockChallengeResult = Fail(error: expected).eraseToAnyPublisher() + + sut.postLockChallenge() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/DataTests-Bridging-Header.h b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/DataTests-Bridging-Header.h new file mode 100644 index 00000000..1b2cb5d6 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/DataTests-Bridging-Header.h @@ -0,0 +1,4 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/MockNetworkError.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/MockNetworkError.swift new file mode 100644 index 00000000..88e21b87 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/MockNetworkError.swift @@ -0,0 +1,25 @@ +// +// MockNetworkError.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Networks + +extension HMHNetworkError { + static public var mockNetworkError: [HMHNetworkError] { + return [ +// .invalidRequest(.invalidURL("Bad URL", <#HMHNetworkError.URLValidationError#>)), + .invalidRequest(.parameterEncodingFailed(.emptyParameters)), +// .invalidRequest(.parameterEncodingFailed(.invalidJSON)), + .invalidRequest(.parameterEncodingFailed(.jsonEncodingFailed)), + .invalidRequest(.parameterEncodingFailed(.missingURL)), + .invalidRequest(.unknownErr), + .decodingFailed(.dataIsNil), + .decodingFailed(.decodingFailed) + ] + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/MockPointService.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/MockPointService.swift new file mode 100644 index 00000000..f23dd391 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/MockPointService.swift @@ -0,0 +1,43 @@ +// +// MockPointService.swift +// Data +// +// Created by 류희재 on 11/4/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Networks + +final public class MockPointService: PointServiceType { + + public init() {} + + public var patchPointUseResult:AnyPublisher! + public var getEarnPointResult: AnyPublisher! + public var getUsagePointResult: AnyPublisher! + public var getPointListResult: AnyPublisher! + public var patchEarnPointResult: AnyPublisher! + + public func patchPointUse() -> AnyPublisher { + return patchPointUseResult + } + + public func getEarnPoint() -> AnyPublisher { + return getEarnPointResult + } + + public func getUsagePoint() -> AnyPublisher { + return getUsagePointResult + } + + public func getPointList() -> AnyPublisher { + return getPointListResult + } + + public func patchEarnPoint(request: UserPointRequest) -> AnyPublisher { + return patchEarnPointResult + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/PointRepositoryTests.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/PointRepositoryTests.swift new file mode 100644 index 00000000..dfc87257 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/Base/PointRepositoryTests.swift @@ -0,0 +1,59 @@ +// +// PointRepositoryTests.swift +// DataTests +// +// Created by 류희재 on 11/4/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain +import Data + +final class PointRepositoryTest: XCTestCase { + + var sut: PointRepositoryType! + var mockService: MockPointService! + var cancelBag: CancelBag! + + override func setUpWithError() throws { + cancelBag = CancelBag() + mockService = MockPointService() + sut = PointRepository(service: mockService) + } + + override func tearDown() { + cancelBag = nil + mockService = nil + sut = nil + } +} + +extension PointRepositoryTest { + public func handleCompletion(expectedError: T? = nil, expectation: XCTestExpectation) -> (Subscribers.Completion) -> Void { + return { completion in + if case .failure(let error) = completion { + XCTAssertEqual(error, expectedError, "Expected error \(String(describing: expectedError)), but got \(error)") + expectation.fulfill() + } else { + XCTFail("Expected failure with error \(String(describing: expectedError)), but received success") + } + } + } + + public func valueHandler(expectation: XCTestExpectation, expectedValue: T) -> (T) -> Void { + return { receivedValue in + XCTAssertEqual(receivedValue, expectedValue, "Received value \(receivedValue) does not match expected data \(expectedValue)") + expectation.fulfill() + } + } + + public func failureExpectedValueHandler() -> (T) -> Void { + return { _ in XCTFail("Expected failure, but got success") } + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointMockData.swift new file mode 100644 index 00000000..a9b5d7e8 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointMockData.swift @@ -0,0 +1,30 @@ +// +// GetEarnPointMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension EarnPointResult { + static public var expectedData: [Int] { + return [100, 0 , -100, 200, 50, 10, 1000] + } + + static public var resultData: [EarnPointResult] { + return [ + .init(earnPoint: 100), + .init(earnPoint: 0), + .init(earnPoint: -100), + .init(earnPoint: 200), + .init(earnPoint: 50), + .init(earnPoint: 10), + .init(earnPoint: 1000) + ] + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointTest.swift new file mode 100644 index 00000000..3d4ea176 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetEarnPointTest/GetEarnPointTest.swift @@ -0,0 +1,86 @@ +// +// getEarnPointTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 받을 포인트 반환 API 데이터 변환 테스트 +extension PointRepositoryTest { + func test_받을포인트반환_정상적인변환() { + let testCases = Array(zip(EarnPointResult.expectedData, EarnPointResult.resultData)) + let expectation = XCTestExpectation(description: "받을 포인트 반환 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getEarnPointResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getEarnPoint() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("받을 포인트 반환 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_받을포인트반환_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.patchPointUseResult = Fail(error: expected).eraseToAnyPublisher() + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: PointError.networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_받을포인트반환_유저가존재하지않을경우_에러반환() { + // Given + let message = "존재하지 않는 유저" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.patchPointUseResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = PointError.userNotFound + let expectation = XCTestExpectation(description: message) + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListMockData.swift new file mode 100644 index 00000000..c971798d --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListMockData.swift @@ -0,0 +1,39 @@ +// +// GetPointListMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension PointDetail { + static public var expectedData: [PointDetail] { + return [ + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]), + .init(point: 100, period: 100, pointStatuses: [.stub]) + ] + } +} + +extension PointListResult { + static public var resultData: [PointListResult] { + return [ + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]), + .init(point: 100, period: 100, challengePointStatuses: [.stub]) + ] + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListTest.swift new file mode 100644 index 00000000..710ec66b --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetPointListTest/GetPointListTest.swift @@ -0,0 +1,86 @@ +// +// GetPointListTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 포인트 수령 여부 조회 API 데이터 변환 테스트 +extension PointRepositoryTest { + func test_포인트수령여부조회_정상적인변환() { + let testCases = Array(zip(PointDetail.expectedData, PointListResult.resultData)) + let expectation = XCTestExpectation(description: "포인트 수령 여부 조회 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getPointListResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getPointList() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("포인트 수령 여부 조회 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_포인트수령여부조회_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.patchPointUseResult = Fail(error: expected).eraseToAnyPublisher() + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: PointError.networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_포인트수령여부조회_챌린지를찾을수없는경우_에러반환() { + // Given + let message = "챌린지를 찾을 수 없습니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.getPointListResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = PointError.challengeNotFound + let expectation = XCTestExpectation(description: "챌린지를 찾을 수 없습니다.") + + sut.getPointList() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointMockData.swift new file mode 100644 index 00000000..426ec1f4 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointMockData.swift @@ -0,0 +1,30 @@ +// +// GetUsagePointResultMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension UsagePointResult { + static public var expectedData: [Int] { + return [100, 0 , -100, 200, 50, 10, 1000] + } + + static public var resultData: [UsagePointResult] { + return [ + .init(usagePoint: 100), + .init(usagePoint: 0), + .init(usagePoint: -100), + .init(usagePoint: 200), + .init(usagePoint: 50), + .init(usagePoint: 10), + .init(usagePoint: 1000) + ] + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointTest.swift new file mode 100644 index 00000000..68d0ff79 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/GetUsagePointTest/GetUsagePointTest.swift @@ -0,0 +1,101 @@ +// +// GetUsagePointTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +// +// getEarnPointTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 사용할 포인트 반환 API 데이터 변환 테스트 +extension PointRepositoryTest { + func test_사용할포인트반환_정상적인변환() { + let testCases = Array(zip(UsagePointResult.expectedData, UsagePointResult.resultData)) + let expectation = XCTestExpectation(description: "포인트 사용 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getUsagePointResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getUsagePoint() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("받을 포인트 반환 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + + func test_사용할포인트반환_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.patchPointUseResult = Fail(error: expected).eraseToAnyPublisher() + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: PointError.networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_사용할포인트반환_유저가존재하지않을경우_에러반환() { + // Given + let message = "존재하지 않는 유저" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.getUsagePointResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = PointError.userNotFound + let expectation = XCTestExpectation(description: message) + + sut.getUsagePoint() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} + + + + + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointMockData.swift new file mode 100644 index 00000000..d71a02fd --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointMockData.swift @@ -0,0 +1,31 @@ +// +// PatchEarnPointMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension UserPointResult { + static public var expectedData: [Int] { + return [100, 0 , -100, 200, 50, 10, 1000] + } + + static public var resultData: [UserPointResult] { + return [ + .init(userPoint: 100), + .init(userPoint: 00), + .init(userPoint: -100), + .init(userPoint: 200), + .init(userPoint: 50), + .init(userPoint: 10), + .init(userPoint: 1000) + ] + } +} + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointTest.swift new file mode 100644 index 00000000..e4ae9e4f --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchEarnPointTest/PatchEarnPointTest.swift @@ -0,0 +1,87 @@ +// +// PatchEarnPointTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 포인트 받기 API 데이터 변환 테스트 +extension PointRepositoryTest { + func test_포인트받기_정상적인변환() { + + let testCases = Array(zip(UserPointResult.expectedData, UserPointResult.resultData)) + let expectation = XCTestExpectation(description: "포인트 받기 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.patchEarnPointResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.patchEarnPoint(date: "2024-03-16") + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("포인트 받기 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_포인트받기_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.patchPointUseResult = Fail(error: expected).eraseToAnyPublisher() + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_포인트받기_이전요청에서이미포인트를받은챌린지인경우_에러반환() { + // Given + let message = "이전 요청에서 이미 포인트를 받은 챌린지" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.patchEarnPointResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = PointError.alreadyEarnedPoints + let expectation = XCTestExpectation(description: "이전 요청에서 이미 포인트를 받은 챌린지") + + sut.patchEarnPoint(date: "2024-03-16") + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseMockData.swift new file mode 100644 index 00000000..a51b503c --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseMockData.swift @@ -0,0 +1,39 @@ +// +// PatchPointUseMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Domain +import Networks + +extension UserPointInfo { + static public var expectedData: [UserPointInfo] { + return [ + .init(usagePoint: 100, remainPoint: 100), + .init(usagePoint: 50, remainPoint: 150), + .init(usagePoint: 200, remainPoint: 0), + .init(usagePoint: 0, remainPoint: 200), + .init(usagePoint: -100, remainPoint: 100), + .init(usagePoint: 100, remainPoint: -100), + .init(usagePoint: -100, remainPoint: -100), + ] + } +} + +extension UsePointResult { + static public var resultData: [UsePointResult] { + return [ + .init(usagePoint: 100, userPoint: 100), + .init(usagePoint: 50, userPoint: 150), + .init(usagePoint: 200, userPoint: 0), + .init(usagePoint: 0, userPoint: 200), + .init(usagePoint: -100, userPoint: 100), + .init(usagePoint: 100, userPoint: -100), + .init(usagePoint: -100, userPoint: -100), + ] + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseTest.swift new file mode 100644 index 00000000..633bd9bb --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/PointRepositoryTests/PatchPointUseTests/PatchPointUseTest.swift @@ -0,0 +1,86 @@ +// +// PatchPointUseTest.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 포인트 사용 API 데이터 변환 테스트 +extension PointRepositoryTest { + func test_포인트사용_정상적인변환() { + let testCases = Array(zip(UserPointInfo.expectedData, UsePointResult.resultData)) + let expectation = XCTestExpectation(description: "여러 포인트 사용 관련 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.patchPointUseResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.patchPointUse() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("포인트 사용 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_포인트사용_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.patchPointUseResult = Fail(error: expected).eraseToAnyPublisher() + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_포인트사용_포인트부족시_에러반환() { + // Given + let message = "포인트가 부족합니다." + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 400, message: message)) + + mockService.patchPointUseResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = PointError.insufficientPoints + let expectation = XCTestExpectation(description: message) + + sut.patchPointUse() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/MockUserService.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/MockUserService.swift new file mode 100644 index 00000000..e4670e95 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/MockUserService.swift @@ -0,0 +1,40 @@ +// +// MockUserService.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Domain +import Networks + +final public class MockUserService: UserServiceType { + + public init() {} + + public var logoutResult:AnyPublisher! + public var deleteAccountResult: AnyPublisher! + public var getUserDataResult: AnyPublisher! + public var getCurrentPointResult: AnyPublisher! + + + public func logout() -> AnyPublisher { + return logoutResult + } + + public func deleteAccount() -> AnyPublisher { + return deleteAccountResult + } + + public func getUserData() -> AnyPublisher { + return getUserDataResult + } + + public func getCurrentPoint() -> AnyPublisher { + return getCurrentPointResult + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/UserRepositoryTests.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/UserRepositoryTests.swift new file mode 100644 index 00000000..dc775de3 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/Base/UserRepositoryTests.swift @@ -0,0 +1,59 @@ +// +// UserRepositoryTests.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain +import Data + +final class UserRepositoryTests: XCTestCase { + + var sut: UserRepositoryType! + var mockService: MockUserService! + var cancelBag: CancelBag! + + override func setUpWithError() throws { + cancelBag = CancelBag() + mockService = MockUserService() + sut = UserRepository(service: mockService) + } + + override func tearDown() { + cancelBag = nil + mockService = nil + sut = nil + } +} + +extension UserRepositoryTests { + public func handleCompletion(expectedError: T? = nil, expectation: XCTestExpectation) -> (Subscribers.Completion) -> Void { + return { completion in + if case .failure(let error) = completion { + XCTAssertEqual(error, expectedError, "Expected error \(String(describing: expectedError)), but got \(error)") + expectation.fulfill() + } else { + XCTFail("Expected failure with error \(String(describing: expectedError)), but received success") + } + } + } + + public func valueHandler(expectation: XCTestExpectation, expectedValue: T) -> (T) -> Void { + return { receivedValue in + XCTAssertEqual(receivedValue, expectedValue, "Received value \(receivedValue) does not match expected data \(expectedValue)") + expectation.fulfill() + } + } + + public func failureExpectedValueHandler() -> (T) -> Void { + return { _ in XCTFail("Expected failure, but got success") } + } +} + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/DeleteAccountTest/DeleteAccountTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/DeleteAccountTest/DeleteAccountTest.swift new file mode 100644 index 00000000..fa4c7db0 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/DeleteAccountTest/DeleteAccountTest.swift @@ -0,0 +1,62 @@ +// +// DeleteAccountTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 회원 탈퇴 API 데이터 변환 테스트 +extension UserRepositoryTests { + func test_회원탈퇴_정상적인변환() { + + let expectation = XCTestExpectation(description: "회원 탈퇴 API 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.deleteAccountResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.deleteAccount() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("회원 탈퇴 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_회원탈퇴_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.deleteAccountResult = Fail(error: expected).eraseToAnyPublisher() + + sut.deleteAccount() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointMockData.swift new file mode 100644 index 00000000..3c17a18a --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointMockData.swift @@ -0,0 +1,34 @@ +// +// GetCurrentPointMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension PointResult { + static public var expectedData: [Int] { + return [100, 0 , -100, 200, 50, 10, 1000] + } +} + +extension PointResult { + static public var resultData: [PointResult] { + return [ + .init(point: 100), + .init(point: 0), + .init(point: -100), + .init(point: 200), + .init(point: 50), + .init(point: 10), + .init(point: 1000) + ] + } +} + + diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointTest.swift new file mode 100644 index 00000000..b019a8b1 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetCurrentPointTest/GetCurrentPointTest.swift @@ -0,0 +1,87 @@ +// +// GetCurrentPointTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 유저 포인트 정보 불러오기 API 데이터 변환 테스트 +extension UserRepositoryTests { + func test_유저포인트정보불러오기_정상적인변환() { + + let testCases = Array(zip(PointResult.expectedData, PointResult.resultData)) + let expectation = XCTestExpectation(description: "유저 포인트 정보 불러오기 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getCurrentPointResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getCurrentPoint() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("유저 포인트 정보 불러오기 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_유저포인트정보불러오기_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.getCurrentPointResult = Fail(error: expected).eraseToAnyPublisher() + + sut.getCurrentPoint() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_유저포인트정보불러오기_유저가존재하지않을경우_에러반환() { + // Given + let message = "존재하지 않는 유저" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.getCurrentPointResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = UserError.userNotFound + let expectation = XCTestExpectation(description: message) + + sut.getCurrentPoint() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserDataTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserDataTest.swift new file mode 100644 index 00000000..83353971 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserDataTest.swift @@ -0,0 +1,87 @@ +// +// GetUserDataTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 유저 정보 불러오기 API 데이터 변환 테스트 +extension UserRepositoryTests { + func test_유저정보불러오기_정상적인변환() { + + let testCases = Array(zip(User.expectedData, UserResult.resultData)) + let expectation = XCTestExpectation(description: "유저 정보 불러오기 레포지토리 변환이 정상적으로 성공했습니다!") + expectation.expectedFulfillmentCount = testCases.count + + for (expectedData, resultData) in testCases { + mockService.getUserDataResult = Just(resultData) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.getUserData() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("유저 정보 불러오기 API 변환 중 실패했습니다: 에러 \(error)") + } + }, receiveValue: valueHandler(expectation: expectation, expectedValue: expectedData)) + .store(in: cancelBag) + } + + wait(for: [expectation], timeout: 1.0 * Double(testCases.count)) + } + + func test_유저정보불러오기_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.getUserDataResult = Fail(error: expected).eraseToAnyPublisher() + + sut.getUserData() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_유저정보불러오기_유저가존재하지않을경우_에러반환() { + // Given + let message = "존재하지 않는 유저" + let networkError = HMHNetworkError.invalidResponse(.invalidStatusCode(code: 404, message: message)) + + mockService.getUserDataResult = Fail(error: networkError) + .eraseToAnyPublisher() + + // When + let expectedError = UserError.userNotFound + let expectation = XCTestExpectation(description: message) + + sut.getUserData() + .sink( + receiveCompletion: handleCompletion( + expectedError: expectedError, + expectation: expectation + ),receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserMockData.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserMockData.swift new file mode 100644 index 00000000..e488f104 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/GetUserDataTest/GetUserMockData.swift @@ -0,0 +1,36 @@ +// +// GetUserMockData.swift +// Data +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +import Domain +import Networks + +extension User { + static public var expectedData: [User] { + return [ + .init(name: "류희재", point: 100), + .init(name: "류희재", point: -100), + .init(name: "류희재", point: 0), + .init(name: "류희재", point: Int.max), + .init(name: "류희재", point: Int.min) + ] + } +} + +extension UserResult { + static public var resultData: [UserResult] { + return [ + .init(name: "류희재", point: 100), + .init(name: "류희재", point: -100), + .init(name: "류희재", point: 0), + .init(name: "류희재", point: Int.max), + .init(name: "류희재", point: Int.min) + ] + } +} diff --git a/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/LogoutTest/LogoutTest.swift b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/LogoutTest/LogoutTest.swift new file mode 100644 index 00000000..46516bac --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Data/Tests/Sources/UserRepositoryTests/LogoutTest/LogoutTest.swift @@ -0,0 +1,62 @@ +// +// LogoutTest.swift +// DataTests +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core +import Domain + +/// 로그아웃 API 데이터 변환 테스트 +extension UserRepositoryTests { + func test_로그아웃_정상적인변환() { + + let expectation = XCTestExpectation(description: "로그아웃 API 관련 레포지토리 변환이 정상적으로 성공했습니다!") + + mockService.logoutResult = Just(()) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + + sut.logout() + .sink(receiveCompletion: { completion in + if case .failure(let error) = completion { + XCTFail("로그아웃 API 변환 중 실패했습니다: 에러 \(error)") + } + expectation.fulfill() + }, receiveValue: { _ in + expectation.fulfill() + }) + .store(in: cancelBag) + + wait(for: [expectation], timeout: 1.0) + } + + func test_로그아웃_네트워크에러발생시_에러반환() { + + let testCases = HMHNetworkError.mockNetworkError + let expectation = XCTestExpectation(description: "에러 발생 시 네트워크 에러 반환") + + for expected in testCases { + mockService.logoutResult = Fail(error: expected).eraseToAnyPublisher() + + sut.logout() + .sink( + receiveCompletion: handleCompletion( + expectedError: .networkError, + expectation: expectation + ), + receiveValue: failureExpectedValueHandler() + ) + .store(in: cancelBag) + + } + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/Auth.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/Auth.swift index 58490139..9b3d2ab0 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/Auth.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/Auth.swift @@ -8,7 +8,7 @@ import Foundation -public struct Auth { +public struct Auth: Equatable { let userId: Int let accessToken: String let refreshToken: String diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/OAuthProviderType.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/OAuthProviderType.swift index 243724c5..449b6e43 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/OAuthProviderType.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Auth/OAuthProviderType.swift @@ -8,7 +8,7 @@ import Foundation -public enum OAuthProviderType { - case kakao - case apple +public enum OAuthProviderType: String { + case kakao = "KAKAO" + case apple = "APPLE" } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/AppInfo.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/AppInfo.swift index 6c006738..652cf5fd 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/AppInfo.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/AppInfo.swift @@ -6,7 +6,7 @@ // Copyright © 2024 HMH-iOS. All rights reserved. // -public struct AppInfo { +public struct AppInfo: Equatable { public let appCode: String public let goalTime: Int @@ -16,4 +16,12 @@ public struct AppInfo { } } +public extension AppInfo { + static var stub: Self { + .init( + appCode: "100000", + goalTime: 10000 + ) + } +} diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeDetail.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeDetail.swift index 3ad0e4ea..eadf12d1 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeDetail.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeDetail.swift @@ -9,20 +9,46 @@ import Foundation public struct ChallengeDetail { - let statuses: [String] + let statuses: [PointStatusEnum] let todayIndex: Int let startDate: String let challengeInfo: ChallengeInfo - public init(statuses: [String], todayIndex: Int, startDate: String, challengeInfo: ChallengeInfo) { + public enum InfoType { + case period + case goalTime + } + + public init(statuses: [PointStatusEnum], todayIndex: Int, startDate: String, challengeInfo: ChallengeInfo) { self.statuses = statuses self.todayIndex = todayIndex self.startDate = startDate self.challengeInfo = challengeInfo } + + public func getTodayIndex() -> Int { + return todayIndex + } + + public func getStatuses() -> [PointStatusEnum] { + return statuses + } + + public func getStartDate() -> String { + return startDate + } + + public func getChallengeInfo(_ infoType: InfoType) -> Int { + switch infoType { + case .period: + return challengeInfo.period + case .goalTime: + return challengeInfo.goalTime + } + } } -public struct ChallengeInfo { +public struct ChallengeInfo: Equatable { public let period: Int public let goalTime: Int public let apps: [AppInfo] @@ -32,4 +58,8 @@ public struct ChallengeInfo { self.goalTime = goalTime self.apps = apps } + + public func getPeriod() -> Int { + return period + } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/DailyChallengeInfo.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeSuccessInfo.swift similarity index 73% rename from HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/DailyChallengeInfo.swift rename to HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeSuccessInfo.swift index 29b174c4..d1789735 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/DailyChallengeInfo.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/ChallengeSuccessInfo.swift @@ -6,9 +6,9 @@ // Copyright © 2024 HMH-iOS. All rights reserved. // -public struct DailyChallengeInfo { - let challengeDate: String - let isSuccess: Bool +public struct ChallengeSuccessInfo { + public let challengeDate: String + public let isSuccess: Bool public init(challengeDate: String, isSuccess: Bool) { self.challengeDate = challengeDate diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/HomeChallengeDetail.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/HomeChallengeDetail.swift new file mode 100644 index 00000000..e793d929 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Challenge/HomeChallengeDetail.swift @@ -0,0 +1,19 @@ +// +// HomeChallengeDetail.swift +// Domain +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +public struct DailyChallengeInfo: Equatable { + public let status: String + public let goalTime: Int + public let apps: [AppInfo] + + public init(status: String, goalTime: Int, apps: [AppInfo]) { + self.status = status + self.goalTime = goalTime + self.apps = apps + } +} diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointDetail.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointDetail.swift index 0c090c99..bac4bb38 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointDetail.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointDetail.swift @@ -8,7 +8,7 @@ import Foundation -public struct PointDetail { +public struct PointDetail: Equatable { let point: Int let period: Int let pointStatuses: [PointStatuse] @@ -18,14 +18,40 @@ public struct PointDetail { self.period = period self.pointStatuses = pointStatuses } + + public func getPoint() -> Int { + return point + } + + public func getPeriod() -> Int { + return period + } + + public func getPointStatuses() -> [PointStatuse] { + return pointStatuses + } } -public struct PointStatuse { +public struct PointStatuse: Equatable { let date: String - let status: String + let status: PointStatusEnum public init(date: String, status: String) { self.date = date - self.status = status + self.status = PointStatusEnum(rawValue: status) ?? .none + } + + public func getDate() -> String { + return date + } + + public func getStatus() -> PointStatusEnum { + return status + } +} + +public extension PointStatuse { + static var stub: Self { + return .init(date: "Asdfasdfasdfas", status: "ASdfasdfasdfas") } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointStatusEnum.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointStatusEnum.swift new file mode 100644 index 00000000..4abe5b4c --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/PointStatusEnum.swift @@ -0,0 +1,44 @@ +// +// PointStatusEnum.swift +// Domain +// +// Created by 이지희 on 11/4/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import SwiftUI + +import DSKit + +public enum PointStatusEnum: String { + case unearned = "UNEARNED" + case earned = "EARNED" + case failure = "FAILURE" + case none = "NONE" + + public var buttonColor: Color { + switch self { + case .unearned: + return DSKitAsset.bluePurpleButton.swiftUIColor + case .earned: + return DSKitAsset.bluePurpleOpacity22.swiftUIColor + case .failure: + return DSKitAsset.gray6.swiftUIColor + case .none: + return DSKitAsset.gray7.swiftUIColor + } + } + + public var titleColor: Color { + switch self { + case .unearned: + return DSKitAsset.whiteBtn.swiftUIColor + case .earned: + return DSKitAsset.bluePurpleOpacity70.swiftUIColor + case .failure: + return DSKitAsset.gray2.swiftUIColor + case .none: + return DSKitAsset.gray3.swiftUIColor + } + } +} diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/UserPointInfo.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/UserPointInfo.swift index 387c1417..89679712 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/UserPointInfo.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/Point/UserPointInfo.swift @@ -8,9 +8,9 @@ import Foundation -public struct UserPointInfo { - let usagePoint: Int - let remainPoint: Int +public struct UserPointInfo: Equatable { + public let usagePoint: Int + public let remainPoint: Int public init(usagePoint: Int, remainPoint: Int) { self.usagePoint = usagePoint diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/User/User.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/User/User.swift index c1f9d891..ecc92ebd 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/User/User.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Entity/User/User.swift @@ -8,7 +8,7 @@ import Foundation -public struct User { +public struct User: Equatable { let name: String let point: Int diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/AuthError.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/AuthError.swift index f528c2c8..975dcfcb 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/AuthError.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/AuthError.swift @@ -9,13 +9,19 @@ import Foundation public enum AuthError: DomainError { + case kakaoAuthrizeError + case appleAuthrizeError case noSignUpInfo case alreadyRegisteredUser case unregisteredUser - case unknown + case networkError public static func error(with message: String) -> AuthError { switch message { + case "카카오 로그인 시도 중 생긴 oauth 오류입니다": + return .kakaoAuthrizeError + case "애플 로그인 시도 중 생긴 oauth 오류입니다": + return .appleAuthrizeError case "온보딩 정보 또는 챌린지 정보 없음": return .noSignUpInfo case "이미 회원가입된 유저입니다.": @@ -23,7 +29,7 @@ public enum AuthError: DomainError { case "회원가입된 유저가 아닙니다. 회원가입을 진행해주세요.": return .unregisteredUser default: - return .unknown + return .networkError } } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/ChallengeError.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/ChallengeError.swift index 265fe9c6..a7841649 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/ChallengeError.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/ChallengeError.swift @@ -12,22 +12,22 @@ public enum ChallengeError: DomainError { case invalidChallengePeriod case goalTimeIsNil case invalidGoalTime - case unknown + case networkError public static func error(with message: String) -> ChallengeError { switch message { case "챌린지를 찾을 수 없습니다.": return .challengeNotFound - case "목표시간은 null일 수 없습니다.": + case "챌린지 기간은 null일 수 없습니다.": return .challengePeriodIsNil case "유효한 숫자의 챌린지 기간을 입력해주세요.": return .invalidChallengePeriod - case "챌린지 기간은 null일 수 없습니다.": + case "목표시간은 null일 수 없습니다.": return .goalTimeIsNil case "유효한 숫자의 목표 시간을 입력해주세요.": return .invalidGoalTime default: - return .unknown + return .networkError } } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/PointError.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/PointError.swift index 89e68722..2a64152b 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/PointError.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/PointError.swift @@ -14,7 +14,7 @@ public enum PointError: DomainError { case challengeNotFound case alreadyEarnedPoints case challengeNotSuccessful - case unknown + case networkError public static func error(with message: String) -> PointError { switch message { @@ -29,7 +29,7 @@ public enum PointError: DomainError { case "성공하지 않은 챌린지": return .challengeNotSuccessful default: - return .unknown + return .networkError } } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/UserError.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/UserError.swift index 13ebfd3d..0341e88a 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/UserError.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Error/Type/UserError.swift @@ -7,21 +7,15 @@ // public enum UserError: DomainError { - case noSignUpInfo - case alreadyRegisteredUser - case unregisteredUser - case unknown + case userNotFound + case networkError public static func error(with message: String) -> UserError { switch message { - case "온보딩 정보 또는 챌린지 정보 없음": - return .noSignUpInfo - case "이미 회원가입된 유저입니다.": - return .alreadyRegisteredUser - case "회원가입된 유저가 아닙니다. 회원가입을 진행해주세요.": - return .unregisteredUser + case "존재하지 않는 유저": + return .userNotFound default: - return .unknown + return .networkError } } } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/AuthRepositoryType.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/AuthRepositoryType.swift index 0f1b899b..a542730a 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/AuthRepositoryType.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/AuthRepositoryType.swift @@ -10,7 +10,7 @@ import Foundation import Combine public protocol AuthRepositoryType { - func authorize(_ serviceType: OAuthProviderType) -> AnyPublisher + func authorize(_ serviceType: OAuthProviderType) -> AnyPublisher func signUp( socialPlatform: String, name: String, diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/ChallengeRepositoryType.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/ChallengeRepositoryType.swift index cdce74c8..49603da4 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/ChallengeRepositoryType.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/ChallengeRepositoryType.swift @@ -10,8 +10,8 @@ import Foundation import Combine public protocol ChallengeRepositoryType { - func getdailyChallenge() -> AnyPublisher - func getSuccesChallenge() -> AnyPublisher<[String], ChallengeError> + func getdailyChallenge() -> AnyPublisher + func postSuccesChallenge(sucessInfo: [ChallengeSuccessInfo]) -> AnyPublisher<[String], ChallengeError> func createChallenge(period: Int, goalTime: Int) -> AnyPublisher func getLockChallenge() -> AnyPublisher func postLockChallenge() -> AnyPublisher diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/UserRepositoryType.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/UserRepositoryType.swift index 6eabfef6..b0f370b7 100644 --- a/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/UserRepositoryType.swift +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/RepositoryInterface/UserRepositoryType.swift @@ -10,8 +10,8 @@ import Foundation import Combine public protocol UserRepositoryType { - func logout() -> AnyPublisher - func deleteAccount() -> AnyPublisher - func getUserData() -> AnyPublisher - func getCurrentPoint() -> AnyPublisher + func logout() -> AnyPublisher + func deleteAccount() -> AnyPublisher + func getUserData() -> AnyPublisher + func getCurrentPoint() -> AnyPublisher } diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Challenge/ChallengeUseCase.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Challenge/ChallengeUseCase.swift new file mode 100644 index 00000000..82d1d343 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Challenge/ChallengeUseCase.swift @@ -0,0 +1,141 @@ +// +// ChallengeUseCase.swift +// Domain +// +// Created by 이지희 on 11/16/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Core + +public protocol ChallngeUseCaseType { + func createChallenge( + period: Int, + goalTime: Int + ) -> AnyPublisher + + func getChallenge() -> AnyPublisher +} + +final class ChallengeUseCase: ChallngeUseCaseType { + private let repository: ChallengeRepositoryType + + public init(repository: ChallengeRepositoryType) { + self.repository = repository + } + + public func createChallenge( + period: Int, + goalTime: Int + ) -> AnyPublisher { + return repository.createChallenge(period: period, goalTime: goalTime) + .eraseToAnyPublisher() + } + + public func getChallenge() -> AnyPublisher { + repository.getChallenge() + .flatMap { [weak self] challengeDetail -> AnyPublisher in + guard let self = self else { + return Fail(error: ChallengeError.networkError).eraseToAnyPublisher() + } + + let noneDates = self.findNoneDates( + statuses: challengeDetail.statuses.map { $0.rawValue }, + todayIndex: challengeDetail.todayIndex, + startDate: challengeDetail.startDate + ) + + guard !noneDates.isEmpty else { + // NONE 상태가 없으면 그대로 반환 + return Just(challengeDetail) + .setFailureType(to: ChallengeError.self) + .eraseToAnyPublisher() + } + + let successInfos = noneDates.map { date in + ChallengeSuccessInfo(challengeDate: date, isSuccess: true) + } + + return self.sendSucessChallenge(challengeSucessInfo: successInfos) + .flatMap { _ -> AnyPublisher in + // 서버 전송 후 상태를 업데이트하여 반환 + let updatedStatuses = challengeDetail.statuses.enumerated().map { index, status -> PointStatusEnum in + if noneDates.contains(self.dateString(for: index, startDate: challengeDetail.startDate)) { + return .unearned + } + return status + } + + let updatedChallenge = ChallengeDetail( + statuses: updatedStatuses, + todayIndex: challengeDetail.todayIndex, + startDate: challengeDetail.startDate, + challengeInfo: challengeDetail.challengeInfo + ) + + return Just(updatedChallenge) + .setFailureType(to: ChallengeError.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + private func sendSucessChallenge(challengeSucessInfo: [ChallengeSuccessInfo]) -> AnyPublisher<[String], ChallengeError> { + return repository.postSuccesChallenge(sucessInfo: challengeSucessInfo) + .eraseToAnyPublisher() + } + + private func findNoneDates(statuses: [String], todayIndex: Int, startDate: String) -> [String] { + var dates: [String] = [] + + let challengeDays = statuses.count + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + guard let start = dateFormatter.date(from: startDate) else { + print("Invalid start date format") + return dates + } + + let calendar = Calendar.current + + if todayIndex > 0 { + for index in 0 ..< todayIndex { + if statuses[index] == "NONE" { + if let newDate = calendar.date(byAdding: .day, value: index, to: start) { + let formattedDate = dateFormatter.string(from: newDate) + dates.append(formattedDate) + } + } + } + } else { + for index in 0 ..< challengeDays { + if statuses[index] == "NONE" { + if let newDate = calendar.date(byAdding: .day, value: index, to: start) { + let formattedDate = dateFormatter.string(from: newDate) + dates.append(formattedDate) + } + } + } + } + + return dates + } + + private func dateString(for index: Int, startDate: String) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + guard let start = dateFormatter.date(from: startDate), + let newDate = Calendar.current.date(byAdding: .day, value: index, to: start) else { + return "" + } + return dateFormatter.string(from: newDate) + } +} diff --git a/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Point/PointUseCase.swift b/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Point/PointUseCase.swift new file mode 100644 index 00000000..b990a06c --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Domain/Sources/Usecase/Point/PointUseCase.swift @@ -0,0 +1,62 @@ +// +// PointUseCase.swift +// Domain +// +// Created by 이지희 on 11/16/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Core + +public protocol PointUseCaseType { + func getPointStatues() -> AnyPublisher<[PointStatuse], PointError> + func getEarnPoint() -> AnyPublisher + func getUsagePoint() -> AnyPublisher + func updatePointUse() -> AnyPublisher + func earnPoint(point: PointStatuse) -> AnyPublisher +} + + +final class PointUseCase: PointUseCaseType { + private let repository: PointRepositoryType + + public init(repository: PointRepositoryType) { + self.repository = repository + } + + /// 포인트 뷰 진입 시 포인트 상태 받기 + public func getPointStatues() -> AnyPublisher<[PointStatuse], PointError> { + return repository.getPointList() + .map { $0.pointStatuses } + .eraseToAnyPublisher() + } + + /// 포인트 획득 시, 획득할 포인트 + public func getEarnPoint() -> AnyPublisher { + return repository.getEarnPoint() + .eraseToAnyPublisher() + } + + /// 포인트 사용 시, 사용할 포인트 + public func getUsagePoint() -> AnyPublisher { + return repository.getUsagePoint() + .eraseToAnyPublisher() + } + + /// 포인트 사용 + public func updatePointUse() -> AnyPublisher { + return repository.patchPointUse() + .eraseToAnyPublisher() + } + + /// 포인트 획득 + /// - return : 포인트 획득 후 포인트 + public func earnPoint(point: PointStatuse) -> AnyPublisher { + return repository.patchEarnPoint(date: point.date) + .eraseToAnyPublisher() + } + +} diff --git a/HMH_Tuist_iOS/Projects/Features/BaseFeatureDependency/Project.swift b/HMH_Tuist_iOS/Projects/Features/BaseFeatureDependency/Project.swift index 673ed884..ec01b324 100644 --- a/HMH_Tuist_iOS/Projects/Features/BaseFeatureDependency/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/BaseFeatureDependency/Project.swift @@ -11,7 +11,7 @@ import DependencyPlugin let project = Project.makeModule( name: "BaseFeatureDependency", - targets: [.dynamicFramework], + targets: [.staticFramework], internalDependencies: [ .domain, .Modules.dsKit, diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Demo/Sources/ChallengeFeatureApp.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Demo/Sources/ChallengeFeatureApp.swift new file mode 100644 index 00000000..7981d7a4 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Demo/Sources/ChallengeFeatureApp.swift @@ -0,0 +1,22 @@ +// +// ChallengeFeatureApp.swift +// ChallengeFeatureInterface +// +// Created by 이지희 on 11/1/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import SwiftUI + +import ChallengeFeature + +@main +struct ChallengeFeatureApp: App { + init() { } + + var body: some Scene { + WindowGroup { + ChallengeView(viewModel: .init()) + } + } +} diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Demo/Sources/Example.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Demo/Sources/Example.swift deleted file mode 100644 index e69de29b..00000000 diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Derived/InfoPlists/ChallengeFeatureInterface-Info.plist b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Derived/InfoPlists/ChallengeFeatureInterface-Info.plist deleted file mode 100644 index 323e5ecf..00000000 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Derived/InfoPlists/ChallengeFeatureInterface-Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Project.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Project.swift index b792be85..9cf468df 100644 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Project.swift @@ -12,8 +12,8 @@ import DependencyPlugin //TODO: 나머지 4개의 모듈 여기로 주입 let project = Project.makeModule( name: "ChallengeFeature", - targets: [.staticFramework, .demo, .interface], - interfaceDependencies: [ + targets: [.staticFramework, .demo], + internalDependencies: [ .Features.BaseFeatureDependency ] ) diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/ChallengeViewModel.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/ChallengeViewModel.swift index 45520fb9..246420bb 100644 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/ChallengeViewModel.swift +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/ChallengeViewModel.swift @@ -7,178 +7,85 @@ import SwiftUI import FamilyControls -import Domain - -enum ChallengeType { - case empty - case normal - case large -} +import Domain +import DSKit +import Core public final class ChallengeViewModel: ObservableObject { - @Published var startDate = "" - @Published var visableStartDate = "" - @Published var todayIndex = 0 - @Published var days = 7 - @Published var appList: [AppInfo] = [] - @Published var statuses: [String] = [] - @Published var titleString = "" - @Published var subTitleString = "" - @Published var challengeType: ChallengeType = .empty - @Published var remainEarnPoint = 0 - @Published var navigateToCreate = false - @Published var isToastPresented = false - - @StateObject var screenViewModel = ScreenTimeViewModel() - - enum PointStatus { - static let unearned = "UNEARNED" - static let earned = "EARNED" - static let failure = "FAILURE" + // MARK: - State + public struct State { + var challenge: ChallengeDetail = .init( + statuses: [], + todayIndex: 0, + startDate: "", + challengeInfo: .init(period: 0, goalTime: 0, apps: []) + ) + var isChallengeExisted: Bool = false + var titleString: String = "" + var subTitleString: String = "" + var navigateToCreate: Bool = false + var navigateToPoint: Bool = false + var isToastPresented: Bool = false } - public init() { - getChallengeInfo() + // MARK: - Action + public enum Action { + case fetchChallengeInfo + case challengeButtonTapped + case updateChallengeInfo(ChallengeDetail) + case setToastVisibility(Bool) + case navigateToPoint(Bool) + case navigateToCreate(Bool) } - func getChallengeType() { - if todayIndex < 0 { - challengeType = .empty - } else if 14 < days { - challengeType = .large - } else { - challengeType = .normal - } - } - - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 - func getChallengeInfo() { -// Providers.challengeProvider.request(target: .getChallenge, -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// self.days = data.period -// self.appList = data.apps -// self.statuses = data.statuses -// self.todayIndex = data.todayIndex -// self.startDate = data.startDate -// self.visableStartDate = self.formatDateString(data.startDate) ?? "" -// -// self.sendSucessIfNeeded() -// self.getChallengeType() -// } - } + // MARK: - Published State + @Published var state: State = .init() - func challengeButtonTapped() { - if !(statuses.contains("UNEARNED")) { - navigateToCreate = true - } else { - isToastPresented = true - } - } + private let challengeUseCase: ChallngeUseCaseType + private let cancelBag: CancelBag = .init() - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 - func addApp(appGoalTime: Int) { -// var applist: [Apps] = [] -// -// screenViewModel.selectedApp.applications.forEach { app in -// applist.append(Apps(appCode: app.localizedDisplayName ?? "basic name", goalTime: appGoalTime)) -// } -// -// screenViewModel.handleStartDeviceActivityMonitoring(includeUsageThreshold: true, interval: appGoalTime) -// -// Providers.challengeProvider.request(target: .addApp(data: AddAppRequestDTO(apps: applist)), instance: BaseResponse.self) { result in -// print(result) -// } - + // MARK: - Init + public init(challengeUseCase: ChallngeUseCaseType) { + self.challengeUseCase = challengeUseCase } - func formatDateString(_ dateString: String) -> String? { - let inputDateFormatter = DateFormatter() - inputDateFormatter.dateFormat = "yyyy-MM-dd" - guard let date = inputDateFormatter.date(from: dateString) else { - return nil + // MARK: - Dispatch Action + public func send(_ action: Action) { + switch action { + case .fetchChallengeInfo: + fetchChallengeInfo() + case .challengeButtonTapped: + handleChallengeButtonTapped() + case .updateChallengeInfo(let challenge): + state.challenge = challenge + checkChallengeExistence(todayIndex: challenge.getTodayIndex()) + case .setToastVisibility(let isVisible): + state.isToastPresented = isVisible + case .navigateToPoint(let shouldNavigate): + state.navigateToPoint = shouldNavigate + case .navigateToCreate(let shouldNavigate): + state.navigateToCreate = shouldNavigate } - - let outputDateFormatter = DateFormatter() - outputDateFormatter.dateFormat = "M월 d일" - let formattedDateString = outputDateFormatter.string(from: date) - - return formattedDateString - } - - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 - func sendFailChallenge(date: String) { -// let midnightDTO = MidnightRequestDTO(finishedDailyChallenges: [FinishedDailyChallenge(challengeDate: date, isSuccess: false)]) -// Providers.challengeProvider.request(target: .postDailyChallenge(data: midnightDTO), instance: BaseResponse.self) { result in -// print("Daily challenge data sent successfully.") -// } } - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 - func sendSucessIfNeeded() { -// let noneDates = findNoneDates(statuses: statuses, todayIndex: todayIndex, startDate: startDate) -// var finishChallenges: [FinishedDailyChallenge] = [] -// -// noneDates.forEach { date in -// finishChallenges.append(FinishedDailyChallenge(challengeDate: date, isSuccess: true)) -// } -// -// if !(finishChallenges.isEmpty) { -// let finishDateDTO = MidnightRequestDTO(finishedDailyChallenges: finishChallenges) -// -// Providers.challengeProvider.request(target: .postDailyChallenge(data: finishDateDTO), instance: BaseResponse.self) { result in -// print("Daily challenge data sent successfully.") -// } -// } + // MARK: - Private Methods + private func fetchChallengeInfo() { + challengeUseCase.getChallenge() + .sink { _ in } receiveValue: { [weak self] challenge in + self?.send(.updateChallengeInfo(challenge)) + } + .store(in: cancelBag) } - func findNoneDates(statuses: [String], todayIndex: Int, startDate: String) -> [String] { - var dates: [String] = [] - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - - guard let start = dateFormatter.date(from: startDate) else { - print("Invalid start date format") - return dates - } - - let calendar = Calendar.current - - if todayIndex > 0 { - for index in 0.. 0 + } } diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/PointViewModel.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/PointViewModel.swift index f25bed3d..d3332534 100644 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/PointViewModel.swift +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/ViewModels/PointViewModel.swift @@ -7,77 +7,103 @@ import Foundation +import Core import Domain final class PointViewModel: ObservableObject { - @Published var challengeDay = 1 - @Published var currentPoint = 0 - @Published public var pointList: [PointStatuse] = [] - @Published var statusList: [String] = [] - @Published var isPresented = false - @Published var earnPoint = 0 - init() { - self.getPointList() - self.getUsagePoint() + // MARK: - State + public struct State { + var period: Int = 0 + var pointStatues: [PointStatuse] = [] + var isPresented: Bool = false + var earnPoint: Int = 0 + var totalPoint: Int = 0 } - func getEarnPoint() { - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 -// Providers.pointProvider.request(target: .getEarnPoint, -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// self.earnPoint = data.earnPoint -// } + // MARK: - Action + public enum Action { + case setEarnPoint(Int) + case setTotalPoint(Int) + case setPointStatues([PointStatuse]) + case setPeriod(Int) + case setToastPresented(Bool) } + @Published var state = State() - func getUsagePoint() { - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 -// Providers.pointProvider.request(target: .getUsagePoint, -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// } + + private var cancelBag = CancelBag() + private let pointUseCase: PointUseCaseType + + init(pointUseCase: PointUseCaseType) { + self.pointUseCase = pointUseCase + loadInitialData() + } + + private func loadInitialData() { + getEarnPoint() + getPointList() + getCurrentPoint() } - // 앱 잠금해제시에 사용될 포인트를 조회하는 api입니다. - func patchEarnPoint(day: Int) { - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 -// let date = pointList[day].challengeDate -// let request = PointRequestDTO(challengeDate: date) -// Providers.pointProvider.request(target: .patchEarnPoint(data: request), -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// self.isPresented = true -// self.statusList[day] = "EARNED" -// self.getPointList() -// } + // MARK: - Actions + func send(_ action: Action) { + switch action { + case .setEarnPoint(let point): + state.earnPoint = point + case .setTotalPoint(let totalPoint): + state.totalPoint = totalPoint + UserDefaults.standard.set(totalPoint, forKey: "totalPoint") + case .setPointStatues(let statues): + state.pointStatues = statues + case .setPeriod(let period): + state.period = period + case .setToastPresented(let isPresented): + state.isPresented = isPresented + } } - // 하루하루 챌린지를 성공하고, 포인트를 받는 버튼을 눌렀을 때, 포인트를 받는 API - func getPointList() { - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 -// Providers.pointProvider.request(target: .getPointList, -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// self.challengeDay = data.period -// self.currentPoint = data.point -// self.pointList = data.challengePointStatuses -// self.pointList.forEach { point in -// self.statusList.append(point.status) -// } -// } + // MARK: - UseCase Call + + func patchEarnPoint(index: Int) { + let point = state.pointStatues[index] + + pointUseCase.earnPoint(point: point) + .sink(receiveCompletion: { _ in }) { point in + print("point \(point)") + } + .store(in: cancelBag) } - // 챌린지 보상 수령 여부를 리스트로 조회하는 api입니다. + func pointStatus(index: Int) -> PointStatusEnum { + return state.pointStatues[index].getStatus() + } - func getCurrentPoint() { - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 -// Providers.pointProvider.request(target: .getCurrentPoint, -// instance: BaseResponse.self) { result in -// guard let data = result.data else { return } -// self.currentPoint = data.point -// } + private func getEarnPoint() { + pointUseCase.getEarnPoint() + .sink { _ in } receiveValue: { [weak self] point in + self?.send(.setEarnPoint(point)) + } + .store(in: cancelBag) } - // 현재 유저 포인트 불러오기 + + private func getPointList() { + pointUseCase.getPointStatues() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { _ in }) { [weak self] statues in + self?.send(.setPointStatues(statues)) + self?.send(.setPeriod(statues.count)) + } + .store(in: cancelBag) + } + + private func getCurrentPoint() { + pointUseCase.getUsagePoint() + .sink(receiveCompletion: {_ in }) { [weak self] totalPoint in + self?.send(.setTotalPoint(totalPoint)) + } + .store(in: cancelBag) + } + } diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeAppListView.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeAppListView.swift deleted file mode 100644 index 76dbcb27..00000000 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeAppListView.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ChallengeAppListView.swift -// HMH_iOS -// -// Created by 이지희 on 5/6/24. -// - -import SwiftUI - -import DSKit - -struct ChallengeAppListView: View { - var body: some View { - HStack { - Image(systemName: "square.fill") - .resizable() - .frame(width: 40, height: 40) - .padding(.trailing, 12) - .padding(.leading, 4) - Text("앱 이름") - .font(Font.text5_medium_16) - .foregroundStyle(DSKitAsset.gray2.swiftUIColor) - Spacer() - Text("1시간 20분") - .font(.text6_medium_14) - .foregroundStyle(DSKitAsset.whiteText.swiftUIColor) - } - .frame(height: 72) - } -} - -#Preview { - ChallengeAppListView() -} diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeView.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeView.swift index 7297af3e..de7ae5f8 100644 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeView.swift +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/ChallengeView.swift @@ -7,251 +7,86 @@ import SwiftUI -import FamilyControls -import DeviceActivity - import DSKit public struct ChallengeView: View { - @StateObject var screenTimeViewModel = ScreenTimeViewModel() - @ObservedObject var viewModel: ChallengeViewModel - - @State private var isExpanded = false - @State private var isPresented = false - - - @State var context: DeviceActivityReport.Context = .init(rawValue: "Challenge Activity") - @State var filter = DeviceActivityFilter( - segment: .daily( - during: Calendar.current.dateInterval( - of: .day, for: .now - )! - ), - users: .all, - devices: .init([.iPhone, .iPad]) - ) - - public init(viewModel: ChallengeViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - NavigationView { - main - .onAppear { } + @ObservedObject var viewModel: ChallengeViewModel + + public init(viewModel: ChallengeViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + NavigationView { + ScrollView { + if viewModel.state.isChallengeExisted { + challengeCalendarView + } else { + emptyChallengeHeaderView } - .showToast(toastType: .pointWarn, isPresented: $viewModel.isToastPresented) + } + .navigationBarTitle(Text(StringLiteral.NavigationBar.challenge)) + .background(DSKitAsset.blackground.swiftUIColor) + .showToast(toastType: .pointWarn, isPresented: $viewModel.state.isToastPresented) } + } } extension ChallengeView { - private var main: some View { - ScrollView { - emptyChallengeHeaderView - listView - .padding(.top, 20) - //TODO: Coordinator 패턴 적용해서 이 부분 뜯어내면 좋을거 같습니다 -// NavigationLink( -// destination: OnboardingContentView(isChallengeMode: true, onboardingState: 2), -// isActive: $viewModel.navigateToCreate, -// label: { -// EmptyView() -// .background(DSKitAsset.blackground.swiftUIColor) -// }) - } - .customNavigationBar(title: StringLiteral.NavigationBar.challenge, - showBackButton: false, - showPointButton: true, point: viewModel.remainEarnPoint) - .background(DSKitAsset.blackground.swiftUIColor) - .onAppear { - viewModel.getChallengeInfo() - } + + // MARK: - Empty View + + private var emptyChallengeHeaderView: some View { + ZStack(alignment: .top) { + Image(uiImage: DSKitAsset.challengeBackground.image) + .resizable() + .aspectRatio(contentMode: .fit) + VStack(alignment: .leading) { + Text(StringLiteral.Challenge.noChallengeTitle) + .font(.text1_medium_22) + .foregroundColor(DSKitAsset.whiteText.swiftUIColor) + .padding(.top, 14) + .padding(.leading, 23) + Spacer() + createChallengeButton + } } - - var emptyChallengeHeaderView: some View { - ZStack(alignment: .top) { - Image(uiImage: DSKitAsset.challengeBackground.image) - .resizable() - .aspectRatio(contentMode: .fit) - VStack(alignment: .leading) { - Text(StringLiteral.Challenge.noChallengeTitle) - .font(.text1_medium_22) - .lineSpacing(22 * 1.5 - 22) - .foregroundStyle(DSKitAsset.whiteText.swiftUIColor) - .padding(.top, 14) - .padding(.leading, 23) - Spacer() - createChallengeButton - - } - } + } + + private var createChallengeButton: some View { + Button(action: { + viewModel.send(.navigateToCreate(true)) + }) { + Text(StringLiteral.Challenge.createButton) + .modifier(CustomButtonStyle()) } - - var createChallengeButton: some View { - Button(action: { - viewModel.challengeButtonTapped() - }, label: { - Text(StringLiteral.Challenge.createButton) - .modifier(CustomButtonStyle()) - } + } + + // MARK: - Calendar + + private var challengeCalendarView: some View { + ZStack(alignment: .top) { + Image(uiImage: DSKitAsset.challengeBackground.image) + .resizable() + .aspectRatio(contentMode: .fit) + VStack(alignment: .leading) { + Text("\(viewModel.state.challenge.getStartDate()) 시작부터") + .font(.text5_medium_16) + .foregroundColor(DSKitAsset.gray1.swiftUIColor) + .padding(.top, 14) + Text("\((viewModel.state.challenge.getTodayIndex()) + 1)일차") + .font(.title1_semibold_32) + .foregroundColor(DSKitAsset.whiteText.swiftUIColor) + .padding(.top, 2) + .padding(.bottom, 32) + HMHCalendar( + days: viewModel.state.challenge.getChallengeInfo(.period), + missionStatus: [], + todayIndex: viewModel.state.challenge.getTodayIndex() ) + .frame(width: UIScreen.main.bounds.width * 0.9) + .padding(.bottom, 20) + } } - - var headerView: some View { - ZStack(alignment: .top) { - Image(uiImage: DSKitAsset.challengeBackground.image) - .resizable() - .aspectRatio(contentMode: .fit) - VStack(alignment: .leading) { - Text("\(viewModel.visableStartDate) 시작부터") - .font(.text5_medium_16) - .foregroundStyle(DSKitAsset.gray1.swiftUIColor) - .padding(.top, 14) - Text("\(viewModel.todayIndex + 1)일차") - .font(.title1_semibold_32) - .foregroundStyle(DSKitAsset.whiteText.swiftUIColor) - .padding(.top, 2) - .padding(.bottom, 32) - if viewModel.challengeType != .empty { - challengeWeekView - .frame(width: UIScreen.main.bounds.width * 0.9) - .padding(.bottom, 20) - } - } - } - } - - var listView: some View { - VStack(alignment: .center) { - HStack (alignment: .center) { - Text("잠금 앱") - .font(.text5_medium_16) - .foregroundStyle(DSKitAsset.gray1.swiftUIColor) - Spacer() - } - .padding(.horizontal, 20) - DeviceActivityReport(context, filter: filter) - .frame(height: 72 * CGFloat(screenTimeViewModel.selectedApp.applicationTokens.count)) - Button(action: { - isPresented = true - }, label: { - Image(uiImage: DSKitAsset.addAppButton.image) - }) - .familyActivityPicker(isPresented: $isPresented, - selection: screenTimeViewModel.$selectedApp) - .onChange(of: screenTimeViewModel.selectedApp) { newSelection in - screenTimeViewModel.selectedApp = newSelection - } - } - .onAppear() { - filter = DeviceActivityFilter( - segment: .daily( - during: Calendar.current.dateInterval( - of: .day, for: .now - ) ?? DateInterval() - ), - users: .all, - devices: .init([.iPhone]), - applications: screenTimeViewModel.selectedApp.applicationTokens, - categories: screenTimeViewModel.selectedApp.categoryTokens - ) - } - } - - var challengeWeekView: some View { - VStack(alignment: .leading) { - if viewModel.days > 0 { - ForEach(1...min(isExpanded ? (viewModel.days + 6) / 7 : 2, (viewModel.days + 6) / 7), id: \.self) { week in - challengeWeekRow(week: week) - } - } - if viewModel.challengeType == .large { - expandButton() - } - } - } - - @ViewBuilder - private func challengeWeekRow(week: Int) -> some View { - HStack { - ForEach(1...7, id: \.self) { day in - challengeDayCell(week: week, day: day) - } - } - .padding(.bottom, 8) - } - - @ViewBuilder - private func expandButton() -> some View { - HStack { - Button(action: { - withAnimation { - isExpanded.toggle() - } - }, label: { - HStack { - Text(isExpanded ? "접기" : "펼치기") - .font(.detail4_medium_12) - .foregroundStyle(DSKitAsset.gray2.swiftUIColor) - Image(uiImage: isExpanded ? DSKitAsset.chevronUp.image : DSKitAsset.chevronDown.image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 8, height: 9) - } - .frame(width: 57, height: 31) - }) - } - .frame(maxWidth: .infinity) - } - - @ViewBuilder - private func challengeDayCell(week: Int, day: Int) -> some View { - let index = (week - 1) * 7 + day - 1 - if index < viewModel.statuses.count { - VStack { - Text("\(index + 1)") - .font(.text6_medium_14) - .foregroundStyle(DSKitAsset.gray2.swiftUIColor) - ZStack { - Circle() - .stroke(index == viewModel.todayIndex ? DSKitAsset.bluePurpleOpacity70.swiftUIColor : DSKitAsset.gray6.swiftUIColor, lineWidth: 2) - .frame(width: 44, height: 44) - switch viewModel.statuses[index] { - case "FAILURE": - Image(uiImage: DSKitAsset.failStar.image) - .resizable() - .frame(width: 24, height: 24) - case "EARNED": - Image(uiImage: DSKitAsset.doneStar.image) - case "UNEARNED": - let gradient = LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color(red: 61/255, green: 23/255, blue: 211/255, opacity: 0), location: 0), - .init(color: Color(red: 61/255, green: 23/255, blue: 211/255, opacity: 0.4), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ) - gradient - .mask(Circle().frame(width: 44, height: 44)) - .frame(width: 44, height: 44) - Image(uiImage: DSKitAsset.successStar.image) - .resizable() - .frame(width: 24, height: 24) - default: - EmptyView() - } - } - } - } - } + } } - - - - -#Preview { - ChallengeView(viewModel: .init()) -} - - diff --git a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/PointView.swift b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/PointView.swift index a25c9269..baf654b3 100644 --- a/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/PointView.swift +++ b/HMH_Tuist_iOS/Projects/Features/ChallengeFeature/Sources/Views/PointView.swift @@ -8,107 +8,74 @@ import SwiftUI import Core +import Domain import DSKit struct PointView: View { - @StateObject var viewModel = PointViewModel() + @StateObject var viewModel: PointViewModel + public var body: some View { - main - .onAppear { - viewModel.getPointList() - } - } -} - -extension PointView { - private var main: some View { ScrollView { listView .padding(.vertical, 16) .padding(.horizontal, 20) } - .showToast(toastType: .earnPoint, isPresented: $viewModel.isPresented) - .customNavigationBar(title: StringLiteral.NavigationBar.point, - showBackButton: true, - showPointButton: true, - isPointView: true, point: viewModel.currentPoint) + .showToast(toastType: .earnPoint, isPresented: $viewModel.state.isPresented) + .customNavigationBar( + title: StringLiteral.NavigationBar.point, + showBackButton: true, + showPointButton: true, + isPointView: true, + point: viewModel.state.totalPoint + ) .background(DSKitAsset.blackground.swiftUIColor) .navigationBarHidden(true) } - +} + +extension PointView { private var listView: some View { - Spacer() - //TODO: 무슨 에러인지 일단 모르겟어서 고쳐봅시다 -// ForEach(viewModel.pointList.indices, id: \.self) { index in -// let point = viewModel.pointList[index] -// HStack { -// VStack(alignment: .leading) { -// Text("\(index + 1)" + StringLiteral.Challenge.pointTitle) -// .font(.text4_semibold_16) -// .foregroundColor(.whiteText) -// .padding(.bottom, 2) -// Text("\(viewModel.challengeDay)" + StringLiteral.Challenge.pointSubTitle) -// .font(.detail4_medium_12) -// .foregroundColor(.gray2) -// } -// Spacer() -// EarnPointButton(day: index, status: viewModel.statusList[index], viewModel: viewModel) -// } -// .frame(height: 80) -// } + ForEach(viewModel.state.pointStatues.indices, id: \.self) { index in + HStack { + VStack(alignment: .leading) { + Text("\(index + 1)" + StringLiteral.Challenge.pointTitle) + .font(.text4_semibold_16) + .foregroundColor(DSKitAsset.whiteText.swiftUIColor) + .padding(.bottom, 2) + Text("\(viewModel.state.period)" + StringLiteral.Challenge.pointSubTitle) + .font(.detail4_medium_12) + .foregroundColor(DSKitAsset.gray2.swiftUIColor) + } + Spacer() + EarnPointButton( + day: index, + status: viewModel.pointStatus(index: index), + viewModel: viewModel + ) + } + .frame(height: 80) + } } } -#Preview { - PointView(viewModel: .init()) -} + struct EarnPointButton: View { let day: Int - let status: String + let status: PointStatusEnum // PointStatusEnum 타입으로 변경 @ObservedObject var viewModel: PointViewModel var body: some View { Button(action: { - viewModel.patchEarnPoint(day: day) + viewModel.patchEarnPoint(index: day) }, label: { - Text(StringLiteral.Challenge.pointButton + " \(viewModel.earnPoint)P") + Text(StringLiteral.Challenge.pointButton + " \(viewModel.state.earnPoint)P") .font(.text4_semibold_16) - .foregroundStyle(buttonTextColor) + .foregroundColor(status.titleColor) // 컬러값 설정 .frame(width: 73, height: 40) - .background(buttonColor) + .background(status.buttonColor) // 컬러값 설정 .clipShape(RoundedRectangle(cornerSize: CGSize(width: 3, height: 3))) }) - .disabled(status != "UNEARNED") - } - - private var buttonColor: Color { - switch status { - case "UNEARNED": - return DSKitAsset.bluePurpleButton.swiftUIColor - case "EARNED": - return DSKitAsset.bluePurpleOpacity22.swiftUIColor - case "FAILURE": - return DSKitAsset.gray6.swiftUIColor - case "NONE": - return DSKitAsset.gray7.swiftUIColor - default: - return DSKitAsset.gray7.swiftUIColor - } - } - - private var buttonTextColor: Color { - switch status { - case "UNEARNED": - return DSKitAsset.whiteBtn.swiftUIColor - case "EARNED": - return DSKitAsset.bluePurpleOpacity70.swiftUIColor - case "FAILURE": - return DSKitAsset.gray2.swiftUIColor - case "NONE": - return DSKitAsset.gray3.swiftUIColor - default: - return DSKitAsset.gray3.swiftUIColor - } + .disabled(status != .unearned) // 상태에 따라 버튼 활성화 설정 } } diff --git a/HMH_Tuist_iOS/Projects/Features/HomeFeature/Project.swift b/HMH_Tuist_iOS/Projects/Features/HomeFeature/Project.swift index 8c0553aa..609f6436 100644 --- a/HMH_Tuist_iOS/Projects/Features/HomeFeature/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/HomeFeature/Project.swift @@ -11,8 +11,8 @@ import DependencyPlugin let project = Project.makeModule( name: "HomeFeature", - targets: [.staticFramework, .demo, .interface], - interfaceDependencies: [ + targets: [.staticFramework, .demo], + internalDependencies: [ .Features.BaseFeatureDependency ] ) diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Derived/InfoPlists/LoginFeatureInterface-Info.plist b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Derived/InfoPlists/LoginFeatureInterface-Info.plist deleted file mode 100644 index 323e5ecf..00000000 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Derived/InfoPlists/LoginFeatureInterface-Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Project.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Project.swift index c782c5b3..2d018a49 100644 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Project.swift @@ -11,8 +11,8 @@ import DependencyPlugin let project = Project.makeModule( name: "LoginFeature", - targets: [.staticFramework, .demo, .interface], - interfaceDependencies: [ + targets: [.staticFramework, .demo], + internalDependencies: [ .Features.BaseFeatureDependency ] ) diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginUseCase.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginUseCase.swift new file mode 100644 index 00000000..272c858e --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginUseCase.swift @@ -0,0 +1,62 @@ +// +// LoginUseCase.swift +// LoginFeature +// +// Created by Seonwoo Kim on 11/8/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Combine + +import Domain +import Core + +public enum LoginResponseType { + case loginSuccess + case loginFailure + case onboardingNeeded +} + +public protocol LoginUseCaseType { + func login(provider: OAuthProviderType) -> AnyPublisher +} + +public final class LoginUseCase: LoginUseCaseType { + + private let repository: AuthRepositoryType + + public init(repository: AuthRepositoryType) { + self.repository = repository + } + + public func login(provider: OAuthProviderType) -> AnyPublisher { + repository.authorize(provider) + .handleEvents(receiveOutput: { socialToken in + UserManager.shared.socialToken = socialToken + }) + .flatMap { [weak self] _ -> AnyPublisher in + guard let self = self else { + return Fail(error: AuthError.appleAuthrizeError).eraseToAnyPublisher() + } + + return self.repository.socialLogin(socialPlatform: provider.rawValue) + .map { _ in LoginResponseType.loginSuccess } + .catch { error -> AnyPublisher in + switch error { + case .unregisteredUser: + return Just(.onboardingNeeded) + .setFailureType(to: AuthError.self) + .eraseToAnyPublisher() + default: + return Just(.loginFailure) + .setFailureType(to: AuthError.self) + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginView.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginView.swift index ea6245b4..00e7f9b2 100644 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginView.swift +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/LoginView.swift @@ -6,9 +6,9 @@ // import SwiftUI -import AuthenticationServices import DSKit +import Domain public struct LoginView: View { @ObservedObject var viewModel: LoginViewModel @@ -23,15 +23,13 @@ public struct LoginView: View { .ignoresSafeArea() VStack(spacing: 10) { //TODO: 이미지 타입 문제거 같은데 지금 해결하기엔 싱싱미역 -// SwipeView(imageNames: [.onboardingFirst, .onboardingSecond, .onboardingThird]) -// .padding(.bottom, 75) - LoginButton(loginProvider: .kakao, viewModel: viewModel) - LoginButton(loginProvider: .apple, viewModel: viewModel) + SwipeView(swipeImages: [DSKitAsset.onboardingFirst.swiftUIImage, DSKitAsset.onboardingSecond.swiftUIImage, DSKitAsset.onboardingThird.swiftUIImage], viewModel: viewModel) + .padding(.bottom, 75) + LoginButton(loginProvider: OAuthProviderType.kakao, viewModel: viewModel) + LoginButton(loginProvider: OAuthProviderType.apple, viewModel: viewModel) } } .frame(maxHeight: .infinity) .padding(.vertical, 22) } } - - diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/ViewModels/LoginViewModel.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/ViewModels/LoginViewModel.swift index 69304827..43f44a40 100644 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/ViewModels/LoginViewModel.swift +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/ViewModels/LoginViewModel.swift @@ -1,106 +1,65 @@ -import SwiftUI -import AuthenticationServices +// +// LoginViewModel.swift +// LoginFeature +// +// Created by Seonwoo Kim on 11/8/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// -import KakaoSDKUser +import Foundation +import Combine import Core +import Domain import DSKit -public class LoginViewModel: NSObject, ObservableObject { +public final class LoginViewModel: ObservableObject { - @Published public var isLoading: Bool = true - @Published var isPresented: Bool = false - @Published var alertType: CustomAlertType = .unlock + private let loginUseCase: LoginUseCaseType + private var cancelBag = CancelBag() - public func handleSplashScreen() { - self.isLoading = false - } + // 화면 이동 로직과 스와이프 인덱스 포함 + @Published private(set) var state = State(loginStatus: .loginFailure, swipeImageIndex: 0) - func handleAppleLogin() { - let request = ASAuthorizationAppleIDProvider().createRequest() - request.requestedScopes = [.fullName, .email] - - let authorizationController = ASAuthorizationController(authorizationRequests: [request]) - authorizationController.delegate = self - authorizationController.performRequests() + public init(loginUseCase: LoginUseCaseType) { + self.loginUseCase = loginUseCase + startImageTimer() } - func handleKakaoLogin() { - if (UserApi.isKakaoTalkLoginAvailable()) { - UserApi.shared.loginWithKakaoTalk {(oauthToken, error) in - if let error = error { - print(error) - } - if let oauthToken = oauthToken{ - let idToken = oauthToken.accessToken - UserManager.shared.socialPlatform = "KAKAO" - UserManager.shared.socialToken = "Bearer " + idToken - self.postSocialLoginData() - } - } - } else { - UserApi.shared.loginWithKakaoAccount {(oauthToken, error) in - if let error = error { - print("🍀",error) - } - if let oauthToken = oauthToken{ - print("kakao success") - UserManager.shared.socialPlatform = "KAKAO" - let idToken = oauthToken.accessToken - UserManager.shared.socialToken = "Bearer " + idToken - self.postSocialLoginData() - } - } - } + // MARK: Action + + enum Action { + case loginButtonDidTap(provider: OAuthProviderType) + case swipeButtonDidTap(index: Int) } - //TODO: 네트워크 부분은 의존성 정리한 뒤에 다시 연결해봅시다 - func postSocialLoginData() { -// let provider = Providers.AuthProvider -// let request = SocialLoginRequestDTO(socialPlatform: UserManager.shared.socialPlatform ?? "") -// -// provider.request(target: .socialLogin(data: request), instance: BaseResponse.self) { data in -// if data.status == 403 { -// UserManager.shared.appStateString = "onboarding" -// } else if data.status == 200 { -// guard let data = data.data else { return } -// UserManager.shared.refreshToken = data.token.refreshToken -// UserManager.shared.accessToken = data.token.accessToken -// UserManager.shared.appStateString = "home" -// } -// } + // MARK: State + + struct State { + var loginStatus: LoginResponseType + var swipeImageIndex: Int } -} - -extension LoginViewModel: ASAuthorizationControllerDelegate { - public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - switch authorization.credential { - case let appleIDCredential as ASAuthorizationAppleIDCredential: - let userIdentifier = appleIDCredential.user - let fullName = appleIDCredential.fullName - - if let identityToken = appleIDCredential.identityToken, - let identifyTokenString = String(data: identityToken, encoding: .utf8) { - UserManager.shared.socialToken = identifyTokenString - UserManager.shared.socialPlatform = "APPLE" - self.postSocialLoginData() - } else { - print("Identity token is nil or failed to convert to string.") - } - default: - break + func send(action: Action) { + switch action { + case .loginButtonDidTap(let provider): + loginUseCase.login(provider: provider) + .sink(receiveCompletion: { _ in }) { [weak self] response in + self?.state.loginStatus = response + } + .store(in: cancelBag) + case .swipeButtonDidTap(let index): + self.state.swipeImageIndex = index } } - func handleAppleIDCredential(_ credential: ASAuthorizationAppleIDCredential) { - let fullName = credential.fullName - let name = (fullName?.familyName ?? "") + (fullName?.givenName ?? "") - UserManager.shared.userName = name - guard let idToken = String(data: credential.identityToken ?? Data(), encoding: .utf8) else { return print("no idToken!!") } + private func startImageTimer() { + Timer.publish(every: 3.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.state.swipeImageIndex = ((self?.state.swipeImageIndex ?? 0) + 1) % 3 + } + .store(in: cancelBag) } - public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - print(error.localizedDescription) - } } diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/LoginButton.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/LoginButton.swift index 1765749d..c2d65a55 100644 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/LoginButton.swift +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/LoginButton.swift @@ -6,42 +6,25 @@ // import SwiftUI -import AuthenticationServices import DSKit - -enum SignInProvider { - case apple - case kakao - - var signInLogoImage: String { - switch self { - case .apple: - return "appleLogo" - case .kakao: - return "kakaoLogo" - } - } -} +import Domain struct LoginButton: View { - let loginProvider: SignInProvider + var loginProvider: OAuthProviderType = .apple @ObservedObject var viewModel: LoginViewModel + var signInLogoImage = DSKitAsset.appleLogo.swiftUIImage var body: some View { Button(action: { - if loginProvider == .apple { - viewModel.handleAppleLogin() - } else if loginProvider == .kakao { - viewModel.handleKakaoLogin() - } + viewModel.send(action: .loginButtonDidTap(provider: loginProvider)) }) { RoundedRectangle(cornerRadius: 6.3) .frame(width:336, height: 51) .foregroundColor(loginProvider == .apple ? DSKitAsset.whiteBtn.swiftUIColor : DSKitAsset.yelloBtn.swiftUIColor) .overlay( HStack { - Image(loginProvider.signInLogoImage) + Image(uiImage: loginProvider == .apple ? DSKitAsset.appleLogo.image : DSKitAsset.kakaoLogo.image) .resizable() .frame(width: 24, height: 24) .padding(.leading, 14) diff --git a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/SwipeView.swift b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/SwipeView.swift index 8e4f56c6..918125a9 100644 --- a/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/SwipeView.swift +++ b/HMH_Tuist_iOS/Projects/Features/LoginFeature/Sources/Views/SwipeView.swift @@ -6,20 +6,22 @@ // import SwiftUI - import DSKit struct SwipeView: View { - var imageNames: [ImageResource] - private let timer = Timer.publish(every: 3.0, on: .main, in: .common).autoconnect() - - @State private var selectedImageIndex: Int = 0 + var swipeImages: [Image] + @ObservedObject var viewModel: LoginViewModel var body: some View { VStack { - TabView(selection: $selectedImageIndex) { - ForEach(0.. - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/HMH_Tuist_iOS/Projects/Features/MyPageFeature/Project.swift b/HMH_Tuist_iOS/Projects/Features/MyPageFeature/Project.swift index f0e84def..2afa4c8d 100644 --- a/HMH_Tuist_iOS/Projects/Features/MyPageFeature/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/MyPageFeature/Project.swift @@ -11,8 +11,8 @@ import DependencyPlugin let project = Project.makeModule( name: "MyPageFeature", - targets: [.staticFramework, .demo, .interface], - interfaceDependencies: [ + targets: [.staticFramework, .demo], + internalDependencies: [ .Features.BaseFeatureDependency ] ) diff --git a/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Derived/InfoPlists/OnboardingFeatureInterface-Info.plist b/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Derived/InfoPlists/OnboardingFeatureInterface-Info.plist deleted file mode 100644 index 323e5ecf..00000000 --- a/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Derived/InfoPlists/OnboardingFeatureInterface-Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Project.swift b/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Project.swift index a5e63178..e2ac8e31 100644 --- a/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Project.swift +++ b/HMH_Tuist_iOS/Projects/Features/OnboardingFeature/Project.swift @@ -11,8 +11,8 @@ import DependencyPlugin let project = Project.makeModule( name: "OnboardingFeature", - targets: [.staticFramework, .demo, .interface], - interfaceDependencies: [ + targets: [.staticFramework, .demo], + internalDependencies: [ .Features.BaseFeatureDependency ] ) diff --git a/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/AchievementStatusType.swift b/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/AchievementStatusType.swift new file mode 100644 index 00000000..9ded79bb --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/AchievementStatusType.swift @@ -0,0 +1,19 @@ +// +// AchievementStatusType.swift +// DSKit +// +// Created by 이지희 on 10/31/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +public enum AchievementStatusType: String { + case fail = "FAILURE" + case earned = "EARNED" + case unearned = "UNEARNED" + + init(from stringValue: String) { + self = AchievementStatusType(rawValue: stringValue.uppercased()) ?? .unearned + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/HMHCalendar.swift b/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/HMHCalendar.swift new file mode 100644 index 00000000..227d99f7 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/DSKit/Sources/Calendar/HMHCalendar.swift @@ -0,0 +1,110 @@ +// +// HMHCalendar.swift +// DSKit +// +// Created by 이지희 on 10/31/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import SwiftUI + +/// 챌린지탭의 캘린더 +public struct HMHCalendar: View { + private var days: Int + private var missionStatus: [AchievementStatusType] + private var todayIndex: Int + + + /// initalizer : 캘린더 생성을 위한 값 세팅 + /// - Parameter : + /// `days`: 설정한 챌린지 일자 + /// `missionStatus` : 미션 현황 (변환하여 전달) + /// `todayIndex` : n일째에 해당 + public init ( + days: Int, + missionStatus: [AchievementStatusType], + todayIndex: Int + ) { + self.days = days + self.missionStatus = missionStatus + self.todayIndex = todayIndex + } + + public var body: some View { + challengeWeekView + } + + var challengeWeekView: some View { + VStack(alignment: .leading) { + ForEach (1 ..< calculateWeekday(days: days)) { // 경고 수정 필요 + challengeWeekRow(week: $0) + } + } + } + + @ViewBuilder + private func challengeWeekRow(week: Int) -> some View { + HStack { + ForEach(1...7, id: \.self) { day in + challengeDayCell(week: week, day: day) + } + } + .padding(.bottom, 8) + } + + @ViewBuilder + private func challengeDayCell(week: Int, day: Int) -> some View { + let index = calculateIndex(week, day) + if index < missionStatus.count { + VStack { + Text("\(index + 1)") + .font(.text6_medium_14) + .foregroundStyle(DSKitAsset.gray2.swiftUIColor) + ZStack { + Circle() + .stroke( strokeColor(index), lineWidth: 2) + .frame(width: 44, height: 44) + switch missionStatus[index] { + case .fail: + Image(uiImage: DSKitAsset.failStar.image) + .resizable() + .frame(width: 24, height: 24) + case .earned: + Image(uiImage: DSKitAsset.doneStar.image) + case .unearned: + let gradient = LinearGradient( + gradient: Gradient(stops: [ + .init(color: DSKitAsset.bluePurpleButton.swiftUIColor.opacity(0), location: 0), + .init(color: DSKitAsset.bluePurpleButton.swiftUIColor.opacity(0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + gradient + .mask(Circle().frame(width: 44, height: 44)) + .frame(width: 44, height: 44) + Image(uiImage: DSKitAsset.successStar.image) + .resizable() + .frame(width: 24, height: 24) + default: + EmptyView() + } + } + } + } + } +} + +extension HMHCalendar { + private func calculateWeekday(days: Int) -> Int { + return (days + 6) / 7 + } + + private func calculateIndex(_ week: Int, _ day: Int) -> Int { + return (week - 1) * 7 + day - 1 + } + + private func strokeColor(_ index: Int) -> Color { + return todayIndex == index ? DSKitAsset.bluePurpleOpacity70.swiftUIColor : DSKitAsset.gray6.swiftUIColor + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/AuthResultDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/AuthResultDTO.swift index 2e2838ac..0e55c430 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/AuthResultDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/AuthResultDTO.swift @@ -9,6 +9,12 @@ import Foundation public struct AuthResult: Decodable { + + public init(userId: Int, token: TokenResult) { + self.userId = userId + self.token = token + } + public let userId: Int public let token: TokenResult } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/TokenDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/TokenDTO.swift index cca6b00e..cc16a9cb 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/TokenDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Auth/Result/TokenDTO.swift @@ -9,6 +9,12 @@ import Foundation public struct TokenResult: Decodable { + public let accessToken: String public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/ChallengeSuccessDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/ChallengeSuccessDTO.swift index 5fd9b7c9..ed9c273d 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/ChallengeSuccessDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/ChallengeSuccessDTO.swift @@ -9,15 +9,28 @@ import Foundation public struct ChallengeSuccessRequest: Encodable { let finishedDailyChallenges: [FinishedDailyChallenge] + + public init(finishedDailyChallenges: [FinishedDailyChallenge]) { + self.finishedDailyChallenges = finishedDailyChallenges + } } public struct FinishedDailyChallenge: Encodable { public let challengeDate: String public let isSuccess: Bool + + public init(challengeDate: String, isSuccess: Bool) { + self.challengeDate = challengeDate + self.isSuccess = isSuccess + } } public struct ChallengeSuccessResult: Decodable { public let statuses: [String] + + public init(statuses: [String]) { + self.statuses = statuses + } } public extension ChallengeSuccessResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/DailyChallegeDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/DailyChallegeDTO.swift new file mode 100644 index 00000000..09e30265 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/DailyChallegeDTO.swift @@ -0,0 +1,31 @@ +// +// DailyChallegeDTO.swift +// Networks +// +// Created by 류희재 on 11/5/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +public struct DailyChallengeResult: Decodable { + public let status: String + public let goalTime: Int + public let apps: [AppInfoDTO] + + public init(status: String, goalTime: Int, apps: [AppInfoDTO]) { + self.status = status + self.goalTime = goalTime + self.apps = apps + } +} + +public extension DailyChallengeResult { + static var stub1: Self { + .init( + status: "1234", + goalTime: 123434, + apps: [.stub]) + } +} + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetChallengeDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetChallengeDTO.swift index 861dc9b6..a6d9227d 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetChallengeDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetChallengeDTO.swift @@ -8,6 +8,14 @@ import Foundation public struct GetChallengeResult: Decodable { + public init(period: Int, statuses: [String], todayIndex: Int, startDate: String, goalTime: Int, apps: [AppInfoDTO]) { + self.period = period + self.statuses = statuses + self.todayIndex = todayIndex + self.startDate = startDate + self.goalTime = goalTime + self.apps = apps + } public let period: Int public let statuses: [String] public let todayIndex: Int diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetLockDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetLockDTO.swift index 566bcb26..263e6c06 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetLockDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Challenge/GetLockDTO.swift @@ -10,6 +10,10 @@ import Foundation public struct GetLockResult: Decodable { public let isLockToday: Bool + + public init(isLockToday: Bool) { + self.isLockToday = isLockToday + } } public extension GetLockResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/EarnPointDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/EarnPointDTO.swift index f2ccd369..a7e5c663 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/EarnPointDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/EarnPointDTO.swift @@ -10,6 +10,10 @@ import Foundation public struct EarnPointResult: Decodable { public let earnPoint: Int + + public init(earnPoint: Int) { + self.earnPoint = earnPoint + } } public extension EarnPointResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/PointListDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/PointListDTO.swift index b324607c..2ab2c7d4 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/PointListDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/PointListDTO.swift @@ -11,6 +11,12 @@ public struct PointListResult: Decodable { public let point: Int public let period: Int public let challengePointStatuses: [ChallengePointStatuses] + + public init(point: Int, period: Int, challengePointStatuses: [ChallengePointStatuses]) { + self.point = point + self.period = period + self.challengePointStatuses = challengePointStatuses + } } public struct ChallengePointStatuses: Decodable { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsagePointDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsagePointDTO.swift index fbc7f835..62199f51 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsagePointDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsagePointDTO.swift @@ -10,6 +10,10 @@ import Foundation public struct UsagePointResult: Decodable { public let usagePoint: Int + + public init(usagePoint: Int) { + self.usagePoint = usagePoint + } } public extension UsagePointResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsePointDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsePointDTO.swift index cea07844..fec81e77 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsePointDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UsePointDTO.swift @@ -11,6 +11,11 @@ import Foundation public struct UsePointResult: Decodable { public let usagePoint: Int public let userPoint: Int + + public init(usagePoint: Int, userPoint: Int) { + self.usagePoint = usagePoint + self.userPoint = userPoint + } } public extension UsePointResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UserPointDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UserPointDTO.swift index 7b7f89e6..4cff8f45 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UserPointDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/Point/UserPointDTO.swift @@ -18,6 +18,10 @@ public struct UserPointRequest: Encodable { public struct UserPointResult: Decodable { public let userPoint: Int + + public init(userPoint: Int) { + self.userPoint = userPoint + } } public extension UserPointResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/PointDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/PointDTO.swift index c2d13b9e..0b5f7fc3 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/PointDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/PointDTO.swift @@ -10,6 +10,10 @@ import Foundation public struct PointResult: Decodable { public let point: Int + + public init(point: Int) { + self.point = point + } } extension PointResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/UserDTO.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/UserDTO.swift index 4b659ffa..2f479fb5 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/UserDTO.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/DTO/User/UserDTO.swift @@ -11,6 +11,11 @@ import Foundation public struct UserResult: Decodable { public let name: String public let point: Int + + public init(name: String, point: Int) { + self.name = name + self.point = point + } } extension UserResult { diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/BaseService.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/BaseService.swift index 0f548307..f990e1e4 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/BaseService.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/BaseService.swift @@ -1,73 +1,98 @@ // -// BaseService.swift +// BaseService_Refactor.swift // Networks // -// Created by 류희재 on 10/14/24. +// Created by 류희재 on 11/7/24. // Copyright © 2024 HMH-iOS. All rights reserved. // import Foundation import Combine +import Core public final class BaseService { + public init() {} + public typealias API = Target - private let requestHandler: RequestHandling + private var retryCnt = 0 + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 10 + return URLSession(configuration: configuration) + }() - public init(requestHandler: RequestHandling = RequestHandler()) { - self.requestHandler = requestHandler - } func requestWithResult(_ target: API) -> AnyPublisher { - return fetchResponse(with: target) - .flatMap { response in - self.validate(response: response, target: target) - .map { _ in response.data! } - .mapError { ErrorHandler.handleError(target, error: $0) } - } - .flatMap { self.decode(data: $0, target: target) } - .eraseToAnyPublisher() - } - - func requestWithNoResult(_ target: API) -> AnyPublisher { - return fetchResponse(with: target) - .flatMap { response -> AnyPublisher in - self.validate(response: response, target: target) // validate 연결 - .map { _ in response.data! } // 성공 시 data 반환 - .eraseToAnyPublisher() - } - .mapError { ErrorHandler.handleError(target, error: $0) } - .flatMap { data -> AnyPublisher in - self.decode(data: data, target: target) - } - .map { _ in () } - .eraseToAnyPublisher() - } + return fetchResponse(with: target) + .flatMap { response in + self.validate(response: response, target: target) + .map { _ in response.data! } + .mapError { $0 } + } + .flatMap { data in + self.decode(data: data) + .mapError { ErrorHandler.handleDecodingError(data: data, decodingType: T.self, error: $0) } + } + .eraseToAnyPublisher() + } + + func requestWithNoResult(_ target: API) -> AnyPublisher { + return fetchResponse(with: target) + .flatMap { response in + self.validate(response: response, target: target) + .map { _ in response.data! } + .mapError { $0 } + } + .flatMap { data -> AnyPublisher in + self.decode(data: data) + .mapError { ErrorHandler.handleDecodingError(data: data, decodingType: VoidResult.self, error: $0) } + .eraseToAnyPublisher() + } + .map { _ in () } + .eraseToAnyPublisher() + } } extension BaseService { + + // dataTask 네트워크 요청 수행 + private func performDataTask(with urlRequest: URLRequest) -> AnyPublisher { + return session.dataTaskPublisher(for: urlRequest) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse else { + throw HMHNetworkError.ResponseError.unhandled + } + return NetworkResponse(data: data, response: httpResponse, error: nil) + } + .mapError { $0 as! HMHNetworkError.ResponseError } + .eraseToAnyPublisher() + } + + /// 네트워크 응답 처리 메소드 private func fetchResponse(with target: API) -> AnyPublisher { - return requestHandler.executeRequest(for: target) - .handleEvents(receiveSubscription: { _ in - NetworkLogHandler.requestLogging(target) - }, receiveOutput: { response in - NetworkLogHandler.responseSuccess(target, result: response) - }) - .mapError { ErrorHandler.handleError(target, error: $0) } + return RequestHandler.createURLRequest(for: target) + .map { $0 } + .handleEvents(receiveOutput: { NetworkLogHandler.requestLogging($0) }) + .flatMap { urlRequest in + self.performDataTask(with: urlRequest) + .mapError { ErrorHandler.handleNoResponseError(target, error: $0) } + } + .handleEvents(receiveOutput: { NetworkLogHandler.responseLogging(target, result: $0) }) .eraseToAnyPublisher() } + /// 응답 유효성 검사 메서드 private func validate(response: NetworkResponse, target: API) -> AnyPublisher { guard response.response.isValidateStatus() else { // 401 인증 오류 발생 시 토큰 갱신 후 재요청 if response.response.unAuthorized() { - return requestHandler.tokenRequest(for: target) - .flatMap { _ in - self.validate(response: response, target: target) - } - .eraseToAnyPublisher() + return refreshTokenAndRetry(for: target) + } // 기타 오류 발생 시 에러 반환 let error = ErrorHandler.handleInvalidResponse(response: response) @@ -79,17 +104,36 @@ extension BaseService { .eraseToAnyPublisher() } - - /// 디코딩 메소드 - private func decode(data: Data, target: API) -> AnyPublisher { + private func decode(data: Data) -> AnyPublisher { return Just(data) .decode(type: GenericResponse.self, decoder: JSONDecoder()) - .mapError { _ in ErrorHandler.handleError(target, error: .decodingFailed(.failed)) } + .mapError { _ in .decodingFailed } .map { $0.data! } .eraseToAnyPublisher() } + private func refreshTokenAndRetry(for target: API) -> AnyPublisher { + retryCnt += 1 + NetworkLogHandler.tokenIntercepterRetryLogging(retryCnt: retryCnt) + return TokenInterceptor.shared.retry(for: session, retryCnt: retryCnt) + .flatMap { tokenResult -> AnyPublisher in + UserManager.shared.accessToken = tokenResult.accessToken + UserManager.shared.refreshToken = tokenResult.refreshToken + return self.fetchResponse(with: target) + .flatMap { newResponse in + self.validate(response: newResponse, target: target) + } + .eraseToAnyPublisher() + } + .catch { error -> AnyPublisher in + UserManager.shared.accessToken = "" + UserManager.shared.refreshToken = "" + return Fail(error: error).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } // HTTP 상태코드 유효성 검사 @@ -103,3 +147,4 @@ extension HTTPURLResponse { } } + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/Config.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/Config.swift index 1ab86ba9..a36f700d 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/Config.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/Config.swift @@ -7,10 +7,11 @@ import Foundation -enum Config { +public enum Config { enum Keys { enum Plist { static let baseURL = "BASE_URL" + static let appKey = "KAKAO_API_KEY" } } @@ -21,12 +22,19 @@ enum Config { return dict }() - static let baseURL: String = "http://3.36.221.133/" +// static let baseURL: String = "http://3.36.221.133" -// static let baseURL: String = { -// guard let key = Config.infoDictionary[Keys.Plist.baseURL] as? String else { -// fatalError("Base URL is not set in plist for this configuration.") -// } -// return key -// }() + static public let baseURL: String = { + guard let key = Config.infoDictionary[Keys.Plist.baseURL] as? String else { + fatalError("Base URL is not set in plist for this configuration.") + } + return key + }() + + static public let appKey: String = { + guard let key = Config.infoDictionary[Keys.Plist.appKey] as? String else { + fatalError("Base URL is not set in plist for this configuration.") + } + return key + }() } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/NetworkLogHandler.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/NetworkLogHandler.swift index e38b0402..34e40d01 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/NetworkLogHandler.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Base/NetworkLogHandler.swift @@ -9,54 +9,170 @@ import Foundation struct NetworkLogHandler { + // 디코딩 로깅 함수 + static func responseDecodingError( + data: Data, + decodingType: T.Type, + error: HMHNetworkError.DecodeError + ) { + let jsonString = String(data: data, encoding: .utf8) ?? "Invalid Data" + + print(""" + ======================== 📥 Response <======================== + ========================= ❌ Decoding Error ========================== + ❗️ Error Type: \(error.description) + ❗️ Expected Decoding Type: \(decodingType) + ❗️ Error Data: \(jsonString) + ============================================================== + """) + } - // 네트워크 요청 로깅 함수 - static func requestLogging(_ endpoint: URLRequestTargetType) { - let url = endpoint.url + (endpoint.path ?? "") - let method = endpoint.method.rawValue - let headers = endpoint.headers ?? [:] - let parameters = endpoint.task + static func tokenIntercepterRetryLogging(retryCnt: Int) { + print(""" + ======================== 🪄 retry <======================== + 🪄 토큰이 만료되어서 retry 작업을 실행합니다 + 🪄 실행 횟수 \(retryCnt) -> \(3-retryCnt)이후 🚨요청횟수 초과🚨 에러가 발생합니다! + ============================================================== + """) + + } + + static func tokenIntercepterRetryError(error: HMHNetworkError) { + print(""" + ========================= ❌ Retry Error ========================== + ❗️ Error Type: \(error.description) + ============================================================== + """) + + } +} + +// 네트워크 응답 로깅 함수 +extension NetworkLogHandler { + static func responseLogging( + _ endpoint: URLRequestTargetType, + result response: NetworkResponse + ) { + print(""" + ======================== 📥 Response <======================== + [EndPoint Information] + 1️⃣ URL: \(endpoint.url) + 2️⃣ Path: \(endpoint.path ?? "없음") + 3️⃣ Method: \(endpoint.method) + 4️⃣ headers: \(endpoint.headers ?? [:]) + 5️⃣ task: \(endpoint.task) + ========================= ✌🏻 응답이 도착했습니다 ========================= + ✌🏻 StatusCode: \(response.response.statusCode) + ✌🏻 responseData: \(String(data: response.data ?? Data(), encoding: .utf8) ?? "No data") + ============================================================== + """ + ) + } + + static func NoResponseError( + _ endpoint: URLRequestTargetType, + error: HMHNetworkError.ResponseError + ) { + print(""" + ======================== 📤 네트워크 응답시 발생한 에러입니다 📤======================== + ========================= ❌ NoResponse Error ❌ ========================== + ❗️ Error Type: \(error.description) + """) + } + + static func invalidReponseError( + response: NetworkResponse, + error: HMHNetworkError.ResponseError + ) { + print(""" + ======================== 📤 네트워크 응답시 발생한 에러입니다 📤======================== + ========================= ❌ invalidResponse Error ❌ ========================== + ❗️ Error Type: \(error.description) + ❗️ responseData: \(String(data: response.data ?? Data(), encoding: .utf8) ?? "No data") + ❗️ StatusCode: \(response.response.statusCode) + ============================================================== + """ + ) + } +} + + +// 네트워크 요청 로깅 함수 +extension NetworkLogHandler { + static func requestLogging(_ request: URLRequest) { + let requestURL = request.url?.absoluteString ?? "없음" + let requestHTTPmethod = request.httpMethod ?? "없음" + let requestHeaders = request.allHTTPHeaderFields ?? [:] + var parameters: Any? + + if let httpBody = request.httpBody { + if let jsonObject = try? JSONSerialization.jsonObject(with: httpBody, options: []), + let encodableParameter = jsonObject as? [String: Any] { + parameters = encodableParameter + } else { return } + } + + // HTTPBody가 없으면 URLQueryItem에서 추출 + if let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + var queryParameters: [String: Any] = [:] + components.queryItems?.forEach { queryItem in + if let value = queryItem.value { + queryParameters[queryItem.name] = value + } + } + parameters = queryParameters + } print(""" ================== 📤 Request ===================> - 📝 URL: \(url) - 📝 HTTP Method: \(method) - 📝 Header: \(headers) - 📝 Parameters: \(parameters) + 📝 URL: \(requestURL) + 📝 HTTP Method: \(requestHTTPmethod) + 📝 Header: \(requestHeaders) + 📝 Parameters: \(parameters ?? "없음") ================================ """) } - // 성공적인 응답 로깅 함수 - static func responseSuccess(_ endpoint: any URLRequestTargetType, result response: NetworkResponse) { + static func requestInvalidURLError( + _ endpoint: any URLRequestTargetType, + result error: HMHNetworkError.RequestError.URLValidationError + ) { let url = endpoint.url + (endpoint.path ?? "") + let method = endpoint.method let headers = endpoint.headers ?? [:] - let responseData = String(data: response.data ?? Data(), encoding: .utf8) ?? "No data" + let task = endpoint.task print(""" - ======================== 📥 Response <======================== - ========================= ✅ Success ========================= - ✌🏻 URL: \(url) - ✌🏻 Header: \(headers) - ✌🏻 Success Data: \(responseData) + ======================== 📤 네트워크 요청 📤======================== + ========================= ❌ InvalidURL Error ❌ ========================== + ❗️ Error Type: \(error.description) + ❗️ 🚨 URL: \(url) 🚨 + ❗️ Method: \(method) + ❗️ Header: \(headers) + ❗️ Task: \(task) ============================================================== """) } - // 에러 응답 로깅 함수 - static func responseError(_ endpoint: any URLRequestTargetType, result error: HMHNetworkError) { - let url = endpoint.url + (endpoint.path ?? "") - let headers = endpoint.headers ?? [:] + static func requestParameterEncodingError( + _ request: URLRequest, + _ parameter: Any? = nil, + error: HMHNetworkError.RequestError.ParameterEncodingError + ) { + let url = request.url?.absoluteString ?? "없음" + let method = request.httpMethod ?? "없음" + let headers = request.allHTTPHeaderFields ?? [:] + let parameterDescription = parameter.map { String(describing: $0) } ?? "없음" print(""" - ======================== 📥 Response <======================== - ========================= ❌ Error ========================== + ======================== 📤 네트워크 요청 📤 ======================== + ========================= ❌ ParameterEncoding Error ❌ ========================== ❗️ Error Type: \(error.description) ❗️ URL: \(url) + ❗️ Method: \(method) ❗️ Header: \(headers) - ❗️ Error Data: \(error.localizedDescription) + ❗️ 🚨 Parameter: \(parameterDescription) 🚨 ============================================================== - """) + """) } } - diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Interceptor/TokenInterceptor.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Interceptor/TokenInterceptor.swift index 58e813b9..ac56d17a 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Interceptor/TokenInterceptor.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Interceptor/TokenInterceptor.swift @@ -16,9 +16,7 @@ struct TokenInterceptor { let cancelBag = CancelBag() static let shared = TokenInterceptor( - service: ReissueAPIService( - requestHandler: RequestHandler() - ) + service: ReissueAPIService() ) private let service: ReissueAPIService @@ -36,9 +34,10 @@ struct TokenInterceptor { func retry(for session: URLSession, retryCnt: Int) -> AnyPublisher { - print(retryCnt) + if retryCnt > retryLimit { - return Fail(error: .timeOutError).eraseToAnyPublisher() + let error = ErrorHandler.handleRetryLimitExceeded() + return Fail(error: error).eraseToAnyPublisher() } else { return service.tokenRefresh() } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/Protocol/RequestHandling.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/Protocol/RequestHandling.swift deleted file mode 100644 index 46b97df6..00000000 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/Protocol/RequestHandling.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// RequestHandling.swift -// Networks -// -// Created by 류희재 on 10/30/24. -// Copyright © 2024 HMH-iOS. All rights reserved. -// - -import Foundation -import Combine - -public protocol RequestHandling { - func executeRequest(for target: T) -> AnyPublisher - func tokenRequest(for target: T) -> AnyPublisher -} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/RequestHandler.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/RequestHandler.swift index 90039ad5..f2adbaf3 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/RequestHandler.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/RequestHandler.swift @@ -1,8 +1,8 @@ // -// RequestHandler.swift +// RequestHandler_Refactor.swift // Networks // -// Created by 류희재 on 10/14/24. +// Created by 류희재 on 11/7/24. // Copyright © 2024 HMH-iOS. All rights reserved. // @@ -11,77 +11,25 @@ import Combine import Core -public class RequestHandler: RequestHandling { - - static let shared = RequestHandler() - - public init() {} - - private var retryCnt = 0 - - private lazy var session: URLSession = { - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 10 - configuration.timeoutIntervalForResource = 10 - return URLSession(configuration: configuration) - }() - - public func executeRequest(for target: T) -> AnyPublisher { - +public struct RequestHandler { + /// URLRequest 생성 + static public func createURLRequest(for target: T) -> AnyPublisher { return target.asURLRequest() - .map { $0 } - .mapError { ErrorHandler.handleError(target, error: .invalidRequest($0)) } - .flatMap { urlRequest in - if target.isWithInterceptor { - return TokenInterceptor.shared.adapt(urlRequest) - } else { - return Just(urlRequest) - .setFailureType(to: HMHNetworkError.self) - .eraseToAnyPublisher() - } - } - .map { $0 } - .flatMap { urlRequest in - self.session.dataTaskPublisher(for: urlRequest) - .tryMap { data, response -> NetworkResponse in - guard let httpResponse = response as? HTTPURLResponse else { - throw HMHNetworkError.ResponseError.unhandled - } - return NetworkResponse(data: data, response: httpResponse, error: nil) - } - .mapError { error -> HMHNetworkError in - if let requestErr = error as? HMHNetworkError.ResponseError { - return .invalidResponse(requestErr) - } else { - return .unknown(error) - } - } - .eraseToAnyPublisher() - } + .mapError { ErrorHandler.handleRequestError($0) } .eraseToAnyPublisher() } - public func tokenRequest(for target: T) -> AnyPublisher { - retryCnt += 1 - return TokenInterceptor.shared.retry(for: session, retryCnt: retryCnt) - .flatMap { tokenResult -> AnyPublisher in - // 업데이트된 토큰을 UserManager에 저장 - UserManager.shared.accessToken = tokenResult.accessToken - UserManager.shared.refreshToken = tokenResult.refreshToken - - // 토큰 갱신 후 요청을 다시 실행 - return self.executeRequest(for: target) - } - .catch { error -> AnyPublisher in - // 토큰 갱신 실패 시 UserManager의 토큰 초기화 - UserManager.shared.accessToken = "" - UserManager.shared.refreshToken = "" - - // 실패를 그대로 반환하여 스트림 종료 - return Fail(error: error).eraseToAnyPublisher() - } - .eraseToAnyPublisher() + /// 인터셉터 적용 + static public func applyInterceptorIfNeeded(_ urlRequest: URLRequest, for target: URLRequestTargetType) -> AnyPublisher { + if target.isWithInterceptor { + return TokenInterceptor.shared.adapt(urlRequest) + } else { + return Just(urlRequest) + .setFailureType(to: HMHNetworkError.self) + .eraseToAnyPublisher() + } } } + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLRequestTargetType.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLRequestTargetType.swift index d2addc30..153dc99c 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLRequestTargetType.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLRequestTargetType.swift @@ -22,14 +22,19 @@ public protocol URLRequestTargetType { extension URLRequestTargetType { public func asURLRequest() -> AnyPublisher { - guard let url = URL(string: self.url) else { - return Fail(error: .invalidURL(self.url)).eraseToAnyPublisher() - } - - var baseURL = url - if let path = self.path { baseURL.appendPathComponent(path) } + var finalURL = self.url - return task.buildRequest(baseURL: baseURL, method: self.method, headers: self.headers) + if let path = self.path { + finalURL = finalURL.trimmingCharacters(in: .whitespacesAndNewlines) + "/" + path.trimmingCharacters(in: .whitespacesAndNewlines) + } + + switch URLValidator.validateURL(finalURL) { + case .failure(let error): + return ErrorHandler.handleInvalidURLError(self, error: error) + + case .success(let validURL): + return task.buildRequest(baseURL: validURL, method: self.method, headers: self.headers) + } } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLValidator.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLValidator.swift new file mode 100644 index 00000000..6642e07c --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Builder/URLValidator.swift @@ -0,0 +1,85 @@ +// +// URLValidaterHandler.swift +// Networks +// +// Created by 류희재 on 11/12/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +public struct URLValidator { + public static func validateURL(_ urlString: String) -> Result { + if urlString.isEmpty { + return .failure(.emptyurlString) + } + + let trimmedURLString = urlString.trimmingCharacters(in: .whitespaces) + + guard let url = URL(string: trimmedURLString) else { + return .failure(.invalidPath) + } + + if !validateScheme(url) { + return .failure(.invalidProtocol) + } + + if !validatePort(url) { + return .failure(.invalidPort) + } + + if !validatePath(url, originalURLString: trimmedURLString) { + return .failure(.invalidPath) + } + + if let query = url.query, !validateQuery(query) { + return .failure(.invalidQueryParameter) + } + + return .success(url) + } + + private static func validateScheme(_ url: URL) -> Bool { + guard let scheme = url.scheme else { return false } + return scheme == "http" || scheme == "https" + } + + private static func validatePort(_ url: URL) -> Bool { + guard let port = url.port else { return true } + return (0...65535).contains(port) + } + + private static func validatePath(_ url: URL, originalURLString: String) -> Bool { + let invalidCharacters: Set = ["|", "<", ">", "{", "}", "\\", "#", "%"] + let path = url.path + + // 기본 경로 유효성 검증 + let hasInvalidCharacters = path.contains(" ") || + path.contains("//") || + path.contains(where: invalidCharacters.contains) + + // URL 인코딩 검사: 원본 문자열과 비교하여 달라졌다면 유효하지 않은 URL로 간주 +// guard let encodedOriginal = originalURLString.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), +// encodedOriginal == url.absoluteString else { +// return false +// } + + // 경로에서 #을 제외한 다른 특수 문자가 있으면 invalid 처리 + if let fragment = url.fragment, !fragment.isEmpty { + return false // fragment는 유효하므로 경로와 별도로 처리 + } + + return !hasInvalidCharacters + } + + private static func validateQuery(_ query: String) -> Bool { + let parameters = query.split(separator: "&") + + for param in parameters { + if !param.contains("=") { + return false + } + } + return !query.contains("&&") + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Config/Task.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Config/Task.swift index c76e4ddb..1c5c0d1f 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Config/Task.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Config/Task.swift @@ -11,12 +11,12 @@ import Combine public enum Task { case requestPlain - case requestParameters(Parameters) - case requestJSONEncodable(Encodable) + case requestParameters(Parameters, urlencoder: URLEncodingType = URLEncoding()) + case requestJSONEncodable(Encodable, jsonencoder: JSONEncodingType = JSONEncoding()) } extension Task { - func buildRequest(baseURL: URL, method: HTTPMethod, headers: [String: String]?) -> AnyPublisher { + public func buildRequest(baseURL: URL, method: HTTPMethod, headers: [String: String]?) -> AnyPublisher { var request = URLRequest(url: baseURL) request.httpMethod = method.rawValue request.allHTTPHeaderFields = headers @@ -27,14 +27,14 @@ extension Task { .setFailureType(to: HMHNetworkError.RequestError.self) .eraseToAnyPublisher() - case .requestParameters(let parameters): - return URLEncoding().encode(request, with: parameters) - .mapError { .parameterEncodingFailed($0) } + case .requestParameters(let parameters, let urlEncoder): + return urlEncoder.encode(request, with: parameters) + .mapError { ErrorHandler.handleParameterEncodingError(request, parameters, error: $0) } .eraseToAnyPublisher() - case .requestJSONEncodable(let encodable): - return JSONEncoding().encode(request, with: encodable) - .mapError { .parameterEncodingFailed($0) } + case .requestJSONEncodable(let encodable, let jsonEncoder): + return jsonEncoder.encode(request, with: encodable) + .mapError { ErrorHandler.handleParameterEncodingError(request, encodable, error: $0) } .eraseToAnyPublisher() } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/JSONEncoding.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/JSONEncoding.swift index 17afb3c5..65eb0346 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/JSONEncoding.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/JSONEncoding.swift @@ -9,20 +9,28 @@ import Foundation import Combine -public struct JSONEncoding: ParameterEncodable { - func encode(_ request: URLRequest, with parameters: Encodable?) -> AnyPublisher { - var request = request - return checkValidURLData(parameters, request.url) - .tryMap { parameters, _ -> URLRequest in +public protocol JSONEncodingType { + func encode(_ request: URLRequest, with parameters: Encodable) -> AnyPublisher +} + +public struct JSONEncoding: JSONEncodingType { + public init() {} + + public func encode(_ request: URLRequest, with parameters: Encodable) -> AnyPublisher { + + return Just(request) + .tryMap { request in + var modifiedRequest = request + do { let data = try JSONEncoder().encode(parameters) - request.httpBody = data - return request + modifiedRequest.httpBody = data + return modifiedRequest } catch { - throw HMHNetworkError.invalidRequest(.parameterEncodingFailed(.jsonEncodingFailed)) + throw HMHNetworkError.RequestError.ParameterEncodingError.jsonEncodingFailed } } - .mapError { $0 as! HMHNetworkError.ParameterEncoding } //TODO: 예외 상황이 없는거 같아서.. + .mapError { _ in HMHNetworkError.RequestError.ParameterEncodingError.unknownErr } .eraseToAnyPublisher() } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/ParameterEncodable.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/ParameterEncodable.swift deleted file mode 100644 index 3e876b4a..00000000 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/ParameterEncodable.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// ParameterEncoding.swift -// Networks -// -// Created by 류희재 on 10/14/24. -// Copyright © 2024 HMH-iOS. All rights reserved. -// - -import Foundation -import Combine - -protocol ParameterEncodable {} - -extension ParameterEncodable { - func checkValidURLData( - _ parameters: Parameters?, - _ url: URL? - ) -> AnyPublisher<(Parameters, URL), HMHNetworkError.ParameterEncoding> { - guard let parameters else { return Fail(error: .emptyParameters).eraseToAnyPublisher() } - guard let url else { return Fail(error: .missingURL).eraseToAnyPublisher() } - - return Just((parameters, url)) - .setFailureType(to: HMHNetworkError.ParameterEncoding.self) - .eraseToAnyPublisher() - } - - func checkValidURLData( - _ parameters: Encodable?, - _ url: URL? - ) -> AnyPublisher<(Encodable, URL), HMHNetworkError.ParameterEncoding> { - guard let parameters else { return Fail(error: .emptyParameters).eraseToAnyPublisher() } - guard let url else { return Fail(error: .missingURL).eraseToAnyPublisher() } - - return Just((parameters, url)) - .setFailureType(to: HMHNetworkError.ParameterEncoding.self) - .eraseToAnyPublisher() - } -} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/URLEncoding.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/URLEncoding.swift index 48487a27..c7457571 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/URLEncoding.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Request/Encoders/URLEncoding.swift @@ -9,21 +9,42 @@ import Foundation import Combine -public struct URLEncoding: ParameterEncodable { - func encode(_ request: URLRequest, with parameters: Parameters?) -> AnyPublisher { - var request = request - - return checkValidURLData(parameters, request.url) - .map { parameters, url -> URLRequest in +public protocol URLEncodingType { + func encode(_ request: URLRequest, with parameters: Parameters) -> AnyPublisher +} + +public struct URLEncoding: URLEncodingType { + + public init() {} + + public func encode(_ request: URLRequest, with parameters: Parameters) -> AnyPublisher { + return Just(request) + .tryMap { request in + // url을 사용하기 때문에 명시적으로 표시를 해주는게 안전하다고 생각함 (하지만 필요없는 구문임.. 고민중) + // 다른 방법으로는 위에서 filter 걸어버리고 강제 옵셔널 처리하면 되긴함 (이게 더 끌리는데 안전하지 못하다는 아쉬움) + guard let url = request.url else { + throw HMHNetworkError.RequestError.ParameterEncodingError.missingURL + } + + guard !parameters.isEmpty else { + throw HMHNetworkError.RequestError.ParameterEncodingError.emptyParameters + } + if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) { - urlComponents.queryItems = parameters.compactMap { key, value in + urlComponents.queryItems = parameters.map { key, value in URLQueryItem(name: key, value: "\(value)") } - request.url = urlComponents.url + var modifiedRequest = request + modifiedRequest.url = urlComponents.url + return modifiedRequest + } else { + throw HMHNetworkError.RequestError.ParameterEncodingError.urlEncodingFailed } - return request } - .mapError { $0 } + .mapError { $0 as! HMHNetworkError.RequestError.ParameterEncodingError } .eraseToAnyPublisher() + } } + + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/ErrorHandler.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/ErrorHandler.swift index afa04f32..9aa418e4 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/ErrorHandler.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/ErrorHandler.swift @@ -10,23 +10,66 @@ import Foundation import Combine public struct ErrorHandler { - static public func handleError(_ target: T, error: HMHNetworkError) -> HMHNetworkError { NetworkLogHandler.responseError(target, result: error) + static public func handleDecodingError(data:Data, decodingType: T.Type, error: HMHNetworkError.DecodeError) -> HMHNetworkError { + + let decodingError: HMHNetworkError = .decodingFailed(error) + NetworkLogHandler.responseDecodingError(data: data, decodingType: T.self, error: error) + return decodingError + } + + static public func handleRetryLimitExceeded() -> HMHNetworkError { + let error: HMHNetworkError = .retryLimitExceeded + NetworkLogHandler.tokenIntercepterRetryError(error: error) return error } +} + +extension ErrorHandler { + static public func handleRequestError(_ error: HMHNetworkError.RequestError) -> HMHNetworkError { + let requestError: HMHNetworkError = .invalidRequest(error) + return requestError + } + + static public func handleParameterEncodingError( + _ request: URLRequest, + _ parameter: Any? = nil, + error: HMHNetworkError.RequestError.ParameterEncodingError + ) -> HMHNetworkError.RequestError { + + NetworkLogHandler.requestParameterEncodingError(request, parameter, error: error) + return .parameterEncodingFailed(error) + } + + static public func handleInvalidURLError( + _ target: T, + error: HMHNetworkError.RequestError.URLValidationError + ) -> AnyPublisher { + + NetworkLogHandler.requestInvalidURLError(target, result: error) + return Fail(error: .invalidURL(error)).eraseToAnyPublisher() + } +} + +extension ErrorHandler { + static public func handleNoResponseError(_ target: T, error: HMHNetworkError.ResponseError) -> HMHNetworkError { + + NetworkLogHandler.NoResponseError(target, error: error) + return .invalidResponse(error) + } - // 유효하지 않은 응답인 경우 에러 처리 static public func handleInvalidResponse(response: NetworkResponse) -> HMHNetworkError { + let error: HMHNetworkError.ResponseError if let data = response.data { - do { - // 에러 응답 모델로 디코딩 시도 - let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: data) - return .invalidResponse(.invalidStatusCode(code: response.response.statusCode, message: errorResponse.message)) - } catch { - return .invalidResponse(.invalidStatusCode(code: response.response.statusCode)) + if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + error = .invalidStatusCode(code: response.response.statusCode, message: errorResponse.message) + } else { + error = .invalidStatusCode(code: response.response.statusCode) } } else { - return .invalidResponse(.invalidStatusCode(code: response.response.statusCode)) + error = .noResponseData } + + NetworkLogHandler.invalidReponseError(response: response, error: error) + return .invalidResponse(error) } } - diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/HMHNetworkError.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/HMHNetworkError.swift index 8bbfd8ee..2e935bbf 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/HMHNetworkError.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Foundation/HMHNetworkError.swift @@ -8,13 +8,13 @@ import Foundation -@frozen public enum HMHNetworkError: Error { +@frozen public enum HMHNetworkError: Error, Equatable { case invalidRequest(RequestError) case invalidResponse(ResponseError) case decodingFailed(DecodeError) case oautheticationError(AuthrizationError) - case timeOutError - case unknown(Error) + case retryLimitExceeded + case unknownError var description: String { switch self { @@ -26,10 +26,10 @@ import Foundation return decodeError.description case .oautheticationError(let authError): return authError.description - case .timeOutError: - return "시간 초과되었습니다!" - case .unknown(let error): - return "알 수 없는 오류 \(error)가 발생하였습니다!" + case .retryLimitExceeded: + return "네트워크 요청 횟수를 초과하였습니다!" + case .unknownError: + return "알 수 없는 오류가 발생하였습니다!" } } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/AuthrizationError.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/AuthrizationError.swift index 746acd1c..e6711f9b 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/AuthrizationError.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/AuthrizationError.swift @@ -9,7 +9,7 @@ import Foundation extension HMHNetworkError { - public enum AuthrizationError: Error { + public enum AuthrizationError: Error, Equatable { case kakaoLoginError case appleLoginError @@ -23,3 +23,9 @@ extension HMHNetworkError { } } } + +extension HMHNetworkError.AuthrizationError { + public func authrizationErrorMessage() -> String { + return description + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/DecodeError.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/DecodeError.swift index 3dd88a53..63450b8f 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/DecodeError.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/DecodeError.swift @@ -9,13 +9,13 @@ import Foundation extension HMHNetworkError { - public enum DecodeError: Error { - case failed + public enum DecodeError: Error, Equatable { + case decodingFailed case dataIsNil var description: String { switch self { - case .failed: + case .decodingFailed: return "디코딩에 실패했습니다" case .dataIsNil: return "데이터가 존재하지 않습니다." diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/RequestError.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/RequestError.swift index 469ddf7a..7a18dd96 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/RequestError.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/RequestError.swift @@ -7,39 +7,65 @@ // extension HMHNetworkError { - public enum RequestError: Error { - case parameterEncodingFailed(ParameterEncoding) // 인코딩시 생기는 에러 - case invalidURL(String) // url이 유효하지 않을때 + public enum RequestError: Error, Equatable { + case parameterEncodingFailed(ParameterEncodingError) // 인코딩시 생기는 에러 + case invalidURL(URLValidationError) // url이 유효하지 않을때 case unknownErr // 그 외 예기치 못한 에러 var description: String { switch self { - case .parameterEncodingFailed(let parameterEncoding): - return "인코딩 시 발생한" + parameterEncoding.description - case .invalidURL(let string): - return "\(string)은 유효한 url이 아닙니다" + case .parameterEncodingFailed(let parameterEncodingError): + return "인코딩 시 발생한" + parameterEncodingError.description + case .invalidURL(let urlValidationError): + return urlValidationError.description case .unknownErr: return "요청 시 발생한 알 수 없는 에러입니다." } } - } - - public enum ParameterEncoding: Error { - case emptyParameters // 파라미터가 비어있을 때 - case missingURL // url이 없을때 - case invalidJSON // json 형식에 맞지 않을때 - case jsonEncodingFailed // json으로 인코딩 할 시 - var description: String { - switch self { - case .emptyParameters: - return "파라미터가 비어있는 에러입니다." - case .missingURL: - return "url이 없습니다" - case .invalidJSON: - return "json 형식에 맞지 않습니다." - case .jsonEncodingFailed: - return "json 인코딩 시 발생한 에러입니다." + @frozen public enum ParameterEncodingError: Error, Equatable { + case emptyParameters // 파라미터가 비어있을 때 + case missingURL // url이 없을때 + case urlEncodingFailed // url로 인코딩 실패시 + case jsonEncodingFailed // json으로 인코딩 실패시 + case unknownErr // 그 외 예기치 못한 에러 + + var description: String { + switch self { + case .emptyParameters: + return "파라미터가 비어있는 에러입니다." + case .missingURL: + return "url이 없습니다" + case .urlEncodingFailed: + return "url 인코딩 시 발생한 에러입니다." + case .jsonEncodingFailed: + return "json 인코딩 시 발생한 에러입니다." + case .unknownErr: + return "파라미터 인코딩 시 알 수 없는 에러입니다" + } + } + } + + public enum URLValidationError: Error, Equatable { + case emptyurlString + case invalidProtocol + case invalidPort + case invalidPath + case invalidQueryParameter + + var description: String { + switch self { + case .emptyurlString: + return "주어진 url이 빈 문자열입니다" + case .invalidProtocol: + return "URL에서 사용하는 프로토콜이 http 또는 https가 아닙니다" + case .invalidPort: + return "URL에서 포트 번호가 잘못되었습니다" + case .invalidPath: + return "URL 경로가 잘못되었습니다" + case .invalidQueryParameter: + return "유효하지 않은 쿼리파라미터입니다.(쿼리 구분자/쿼리 파라미터 확인)" + } } } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/ResponseError.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/ResponseError.swift index eace376b..f4e65b05 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/ResponseError.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Foundation/Response/Error/Type/ResponseError.swift @@ -9,10 +9,12 @@ import Foundation extension HMHNetworkError { - public enum ResponseError: Error { + public enum ResponseError: Error, Equatable { case cancelled case unhandled + case noResponseData case invalidStatusCode(code: Int, message: String? = nil) + case unknown var description: String { switch self { @@ -20,8 +22,12 @@ extension HMHNetworkError { return "취소되었습니다." case .unhandled: return "응답이 올바르지 않습니다" + case .noResponseData: + return "응답값이 존재하지 않습니다" case .invalidStatusCode(let code, let errMessage): - return "\(StatusCodeError.from(code).description)/n\(errMessage ?? "추가적인 에러 메세지는 없습니다")" + return "\(StatusCodeError.from(code).description) -> \(errMessage ?? "추가적인 에러 메세지는 없습니다")" + case .unknown: + return "알 수 없는 응답에러입니다" } } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeAPI.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeAPI.swift index 3af099c1..093eccf2 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeAPI.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeAPI.swift @@ -10,7 +10,7 @@ import Foundation public enum ChallengeAPI { case getdailyChallenge - case getSuccesChallenge + case postSucessChallenge(request: ChallengeSuccessRequest) case createChallenge(request: CreateChallengeRequest) case getLockChallenge case postLockChallenge @@ -28,7 +28,7 @@ extension ChallengeAPI: BaseAPI { switch self { case .getdailyChallenge: return Paths.getChallenge - case .getSuccesChallenge: + case .postSucessChallenge: return Paths.getSuccesChallenge case .createChallenge: return Paths.createChallenge @@ -49,8 +49,8 @@ extension ChallengeAPI: BaseAPI { switch self { case .getdailyChallenge: return .get - case .getSuccesChallenge: - return .get + case .postSucessChallenge: + return .post case .createChallenge: return .post case .getLockChallenge: @@ -70,8 +70,8 @@ extension ChallengeAPI: BaseAPI { switch self { case .getdailyChallenge: return .requestPlain - case .getSuccesChallenge: - return .requestPlain + case .postSucessChallenge(let request): + return .requestJSONEncodable(request) case .createChallenge(let request): return .requestJSONEncodable(request) case .getLockChallenge: @@ -91,7 +91,7 @@ extension ChallengeAPI: BaseAPI { switch self { case .getdailyChallenge: return APIHeaders.hasTokenWithTimeZoneHeader - case .getSuccesChallenge: + case .postSucessChallenge: return APIHeaders.hasTokenWithTimeZoneHeader //안되면 contenttype 빼고 case .createChallenge: return APIHeaders.hasTokenWithAllHeader diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeService.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeService.swift index 5afc46de..cf5c7535 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeService.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Challenge/ChallengeService.swift @@ -12,8 +12,8 @@ import Combine public typealias ChallengeService = BaseService public protocol ChallengeServiceType { - func getdailyChallenge() -> AnyPublisher - func getSuccesChallenge() -> AnyPublisher + func getDailyChallenge() -> AnyPublisher + func postSuccesChallenge(request: ChallengeSuccessRequest) -> AnyPublisher func createChallenge(request: CreateChallengeRequest) -> AnyPublisher func getLockChallenge() -> AnyPublisher func postLockChallenge() -> AnyPublisher @@ -23,12 +23,12 @@ public protocol ChallengeServiceType { } extension ChallengeService: ChallengeServiceType { - public func getdailyChallenge() -> AnyPublisher { + public func getDailyChallenge() -> AnyPublisher { return requestWithResult(.getdailyChallenge) } - public func getSuccesChallenge() -> AnyPublisher { - return requestWithResult(.getSuccesChallenge) + public func postSuccesChallenge(request: ChallengeSuccessRequest) -> AnyPublisher { + return requestWithResult(.postSucessChallenge(request: request)) } public func createChallenge(request: CreateChallengeRequest) -> AnyPublisher { @@ -57,13 +57,13 @@ extension ChallengeService: ChallengeServiceType { } struct StubChallengeService: ChallengeServiceType { - func getdailyChallenge() -> AnyPublisher { + func getDailyChallenge() -> AnyPublisher { return Just(.stub1) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - func getSuccesChallenge() -> AnyPublisher { + func postSuccesChallenge(request: ChallengeSuccessRequest) -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Apple/OAuthAppleService.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Apple/OAuthAppleService.swift index 44aa46c7..b1491e19 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Apple/OAuthAppleService.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Apple/OAuthAppleService.swift @@ -15,13 +15,13 @@ public final class OAuthAppleService: OAuthServiceType { private let appleLoginManager = AppleLoginManager() - public func authorize() -> AnyPublisher { + public func authorize() -> AnyPublisher { return login() .map { $0 } .eraseToAnyPublisher() } - private func login() -> AnyPublisher { + private func login() -> AnyPublisher { return self.appleLoginManager.handleAuthorizationAppleIDButtonPress() .tryMap { result -> String in guard @@ -33,7 +33,7 @@ public final class OAuthAppleService: OAuthServiceType { } return idTokenString } - .mapError { _ in HMHNetworkError.AuthrizationError.appleLoginError } + .mapError { _ in HMHNetworkError.oautheticationError(.appleLoginError) } .eraseToAnyPublisher() } } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Foundation/OAuthSerivce.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Foundation/OAuthSerivce.swift index 4db76c55..9e4ae6b4 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Foundation/OAuthSerivce.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Foundation/OAuthSerivce.swift @@ -10,21 +10,21 @@ import Foundation import Combine public protocol OAuthServiceType { - func authorize() -> AnyPublisher + func authorize() -> AnyPublisher } final class OAuthSerivce: OAuthServiceType { - func authorize() -> AnyPublisher { + func authorize() -> AnyPublisher { return Just("") - .setFailureType(to: HMHNetworkError.AuthrizationError.self) + .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } } final class StubOAuthService: OAuthServiceType { - func authorize() -> AnyPublisher { + func authorize() -> AnyPublisher { return Just("") - .setFailureType(to: HMHNetworkError.AuthrizationError.self) + .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Kakao/OAuthKakoService.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Kakao/OAuthKakoService.swift index 8d0f27ba..46615d92 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Kakao/OAuthKakoService.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/OAuth/Kakao/OAuthKakoService.swift @@ -17,27 +17,27 @@ public final class OAuthKakaoService: OAuthServiceType { public init() {} let cancelBag = CancelBag() - public func authorize() -> AnyPublisher { + public func authorize() -> AnyPublisher { return login() .map { $0.accessToken } .eraseToAnyPublisher() } - private func login() -> Future { + private func login() -> Future { return Future { promise in let userApi = UserApi.shared if UserApi.isKakaoTalkLoginAvailable() { userApi.loginWithKakaoTalk { (token, error) in guard let token else { - return promise(.failure(.kakaoLoginError)) + return promise(.failure(.oautheticationError(.kakaoLoginError))) } promise(.success(token)) } } else { userApi.loginWithKakaoAccount { (token, error) in guard let token else { - return promise(.failure(.kakaoLoginError)) + return promise(.failure(.oautheticationError(.kakaoLoginError))) } promise(.success(token)) } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Point/PointService.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Point/PointService.swift index b8c17a05..e4bb77b3 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Point/PointService.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Sources/Service/Point/PointService.swift @@ -41,38 +41,39 @@ extension PointService: PointServiceType { } } -struct StubPointServicee: PointServiceType { - func patchPointUse() -> AnyPublisher { +public struct StubPointService: PointServiceType { + + public init() {} + + public func patchPointUse() -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - func getEarnPoint() -> AnyPublisher { + public func getEarnPoint() -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - func getUsagePoint() -> AnyPublisher { + public func getUsagePoint() -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - func getPointList() -> AnyPublisher { + public func getPointList() -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - func patchEarnPoint(request: UserPointRequest) -> AnyPublisher { + public func patchEarnPoint(request: UserPointRequest) -> AnyPublisher { return Just(.stub) .setFailureType(to: HMHNetworkError.self) .eraseToAnyPublisher() } - - } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockRequestHandler.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockRequestHandler.swift deleted file mode 100644 index a0f2b691..00000000 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockRequestHandler.swift +++ /dev/null @@ -1,74 +0,0 @@ -//// -//// MockRequestHandler.swift -//// Networks -//// -//// Created by 류희재 on 10/30/24. -//// Copyright © 2024 HMH-iOS. All rights reserved. -//// -// -//import Combine -//import Foundation -// -//import Networks -// -//import Foundation -//import Combine -// -//public class MockRequestHandler: RequestHandling { -// public func executeRequest(for target: T) -> AnyPublisher where T : Networks.URLRequestTargetType { -// <#code#> -// } -// -// public func tokenRequest(for target: T) -> AnyPublisher where T : Networks.URLRequestTargetType { -// <#code#> -// } -// -// -// private lazy var session: URLSession = { -// let configuration = URLSessionConfiguration.default -// configuration.timeoutIntervalForRequest = 10 -// configuration.timeoutIntervalForResource = 10 -// // TODO: Interceptor 추가 -// return URLSession(configuration: configuration) -// }() -// -// public func executeRequest(for target: T, isWithInterceptor: Bool) -> AnyPublisher { -// return target.asURLRequest() -// .map { $0 } -// .mapError { ErrorHandler.handleError(target, error: .invalidRequest($0)) } -//// .flatMap { urlRequest in -//// if isWithInterceptor { -////// return TokenInterceptor.shared.adapt(urlRequest) -//// } else { -//// return Just(urlRequest) -//// .setFailureType(to: HMHNetworkError.self) -//// .eraseToAnyPublisher() -//// } -//// } -//// .map { $0 } -// .flatMap { urlRequest in -// self.session.dataTaskPublisher(for: urlRequest) -// .tryMap { data, response -> NetworkResponse in -// guard let httpResponse = response as? HTTPURLResponse else { -// throw HMHNetworkError.ResponseError.unhandled -// } -// return NetworkResponse(data: data, response: httpResponse, error: nil) -// } -// .mapError { error -> HMHNetworkError in -// if let requestErr = error as? HMHNetworkError.ResponseError { -// return .invalidResponse(requestErr) -// } else { -// return .unknown(error) -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() -// } -// -// public func tokenRequest() { -//// TokenInterceptor.shared.retry(for: session) -// } -//} -// -// diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockTargetType.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockTargetType.swift deleted file mode 100644 index dc3b9e24..00000000 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Mock/MockTargetType.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// MockTargetType.swift -// Networks -// -// Created by 류희재 on 10/30/24. -// Copyright © 2024 HMH-iOS. All rights reserved. -// - -import Foundation -import Combine - -import Networks - -struct MockTarget: URLRequestTargetType { - var url: String = "https://example.com" - var path: String? = "/test" - var method: HTTPMethod = .get - var headers: [String : String]? = nil - var task: Task = .requestPlain - var isWithInterceptor: Bool = false - - func asURLRequest() -> AnyPublisher { - guard let url = URL(string: self.url) else { - return Fail(error: .invalidURL(self.url)).eraseToAnyPublisher() - } - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - return Just(request).setFailureType(to: HMHNetworkError.RequestError.self).eraseToAnyPublisher() - } -} - diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/EncodableParameterMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/EncodableParameterMockData.swift new file mode 100644 index 00000000..121e52ef --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/EncodableParameterMockData.swift @@ -0,0 +1,50 @@ +// +// RequestValidatorMockData.swift +// Networks +// +// Created by 류희재 on 11/10/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +// RequestValidatorMockData.swift +// Networks +// +// Created by 류희재 on 11/10/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Networks + +// MARK: - Encodable Parameter Mock Data + +struct EncodableParameterMockData { + static let validEncodableParameter: Encodable = ["name": "John", "age": "25"] + static let nilParameters: Encodable? = nil +} + +// MARK: - Mock Data Aggregation + +extension EncodableParameterMockData { + struct NonEncodable { + var name: String + var age: Int + } + + static let nonEncodableParameter: [String: Any?] = [ + "user": NonEncodable(name: "John", age: 30), + ] +} + +extension EncodableParameterMockData { + static let validParameters: [Encodable] = [ + ["key1": "value1", "key2": "value2"], + ["specialChars": "!@#$%^&*()"], + ["space": "a value with spaces"], + ["korean": "한글"], + ["integer": 123, "float": 45.67], + ["isTrue": true, "isFalse": false], + ["empty": ""], + ["Key": "UpperCase", "key": "LowerCase"] + ] +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/MockResult.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/MockResult.swift new file mode 100644 index 00000000..aef0aab8 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/MockResult.swift @@ -0,0 +1,14 @@ +// +// MockResult.swift +// NetworksTests +// +// Created by 류희재 on 11/19/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +struct MockResult: Decodable { + let testName: String + let testAge: Int +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/NetworkResponseMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/NetworkResponseMockData.swift new file mode 100644 index 00000000..04003b15 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/NetworkResponseMockData.swift @@ -0,0 +1,34 @@ +// +// NetworkResponseMockData.swift +// NetworksTests +// +// Created by 류희재 on 11/19/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Networks + +struct NetworkResponseMockData { + static func responseWith(statusCode: Int, data: Data?) -> NetworkResponse { + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return NetworkResponse(data: data, response: httpResponse, error: nil) + } + + static let validErrorData = """ + { + "status": 404, + "message": "테스트를 위해서 사용된 에러메세지입니다!" + } + """.data(using: .utf8) + + + static let invalidErrorData = """ + { "invalid": "data" } + """.data(using: .utf8) +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/ParameterMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/ParameterMockData.swift new file mode 100644 index 00000000..06949e86 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/ParameterMockData.swift @@ -0,0 +1,65 @@ +import Foundation +import Networks + +public struct ParameterValidatorMockData { + static public let validParameter: Parameters = [ + "username": "hellohidi", + "age": 25 + ] + + static public let nilParameters: Parameters? = nil + static public let emptyParameters: Parameters = [:] + + static public let validParameters: [(parameters: Parameters, expectedQueryItems: [URLQueryItem])] = [ + (simpleKeyValue, [URLQueryItem(name: "key1", value: "value1"), URLQueryItem(name: "key2", value: "value2")]), + (specialCharacters, [URLQueryItem(name: "symbol", value: "!@#$%^&*()")]), + (parametersWithSpaces, [URLQueryItem(name: "space", value: "a value with spaces")]), + (multiLanguageCharacters, [URLQueryItem(name: "korean", value: "한글")]), + (numericParameters, [URLQueryItem(name: "integer", value: "123"), URLQueryItem(name: "float", value: "45.67")]), + (booleanValues, [URLQueryItem(name: "isTrue", value: "true"), URLQueryItem(name: "isFalse", value: "false")]), + (emptyStrings, [URLQueryItem(name: "empty", value: "")]), + (caseSensitiveKeys, [URLQueryItem(name: "Key", value: "UpperCase"), URLQueryItem(name: "key", value: "LowerCase")]), + (jsonStringParameter, [URLQueryItem(name: "json", value: "{\"name\":\"test\",\"age\":30}")]) + ] + + // 각 파라미터 케이스 + static public let simpleKeyValue: Parameters = [ + "key1": "value1", + "key2": "value2" + ] + + static public let specialCharacters: Parameters = [ + "symbol": "!@#$%^&*()" + ] + + static public let parametersWithSpaces: Parameters = [ + "space": "a value with spaces" + ] + + static public let multiLanguageCharacters: Parameters = [ + "korean": "한글" + ] + + static public let numericParameters: Parameters = [ + "integer": 123, + "float": 45.67 + ] + + static public let booleanValues: Parameters = [ + "isTrue": true, + "isFalse": false + ] + + static public let emptyStrings: Parameters = [ + "empty": "" + ] + + static public let caseSensitiveKeys: Parameters = [ + "Key": "UpperCase", + "key": "LowerCase" + ] + + static public let jsonStringParameter: Parameters = [ + "json": "{\"name\":\"test\",\"age\":30}" + ] +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLRequestMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLRequestMockData.swift new file mode 100644 index 00000000..59766009 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLRequestMockData.swift @@ -0,0 +1,43 @@ +// +// URLEncodingMockData.swift +// Networks +// +// Created by 류희재 on 11/11/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation + +public struct URLRequestMockData { + static let mockURL = URL(string: "https://example.com")! + + static public let validRequestData: URLRequest = { + var request = URLRequest(url: mockURL) + request.httpMethod = "GET" + request.allHTTPHeaderFields = [ + "Content-Type": "application/json" + ] + return request + }() + + static public let nilURLRequest: URLRequest = { + var request = URLRequest(url: mockURL) + request.url = nil + request.httpMethod = "GET" + return request + }() + + static public let validURLNoParametersRequest: URLRequest = { + var request = URLRequest(url: mockURL) + request.httpMethod = "GET" + return request + }() + + static public let invalidURLRequest: URLRequest = { + let url = URL(string: "https://example.com/path?existingParam=123")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + return request + }() +} + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLTargetTypeMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLTargetTypeMockData.swift new file mode 100644 index 00000000..e2563843 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLTargetTypeMockData.swift @@ -0,0 +1,38 @@ +// +// URLTargetTypeMockData.swift +// NetworksTests +// +// Created by 류희재 on 11/19/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Networks + +public struct MockURLRequestTarget: URLRequestTargetType { + public var url: String + public var path: String? + public var method: HTTPMethod + public var headers: [String: String]? + public var task: Task + public var isWithInterceptor: Bool + + public init( + url: String = "http://example.com", + path: String? = "validPath", + method: HTTPMethod = .get, + headers: [String: String]? = ["Content-Type" : "application/json"], + task: Task = .requestPlain, + isWithInterceptor: Bool = false + ) { + self.url = url + self.path = path + self.method = method + self.headers = headers + self.task = task + self.isWithInterceptor = isWithInterceptor + } +} + +extension MockURLRequestTarget { + static var mockTargetType = MockURLRequestTarget.init() +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLValidatorMockData.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLValidatorMockData.swift new file mode 100644 index 00000000..860adaeb --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/MockData/URLValidatorMockData.swift @@ -0,0 +1,124 @@ +// +// URLValidatorMockData.swift +// NetworksTests +// +// Created by 류희재 on 11/14/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import Networks + +struct URLValidatorMockData { + let validURLData: [String] = [ + ] + + static let invalidURLData: [String] = invalidProtocolURL + invalidPortURL + invalidPathURL + invalidQueryURL + + static let urlTargetTypeMockData: [(url: String, path: String?, expectedURL: String?, error: HMHNetworkError.RequestError.URLValidationError?)] = [ + ( + url: "http://example.com", + path: "validPath", + expectedURL: "http://example.com/validPath", + error: nil + ), + ( + url: "https://example.com", + path: "api/v1", + expectedURL: "https://example.com/api/v1", + error: nil + ), + ( + url: "", + path: nil, + expectedURL: nil, + error: .emptyurlString + ), + ( + url: "www.example.com", + path: nil, + expectedURL: nil, + error: .invalidProtocol + ), + ( + url: "htp://example.com", + path: nil, expectedURL: nil, + error: .invalidProtocol + ), + ( + url: "https://example.com:99999", + path: nil, + expectedURL: nil, + error: .invalidPort + ), + ( + url: "http://example.com", + path: "path|with|pipes", + expectedURL: nil, + error: .invalidPath + ), + ( + url: "http://example.com", + path: "path with spaces", + expectedURL: nil, + error: .invalidPath + ), + ( + url: "http://example.com", + path: "/double/slash", + expectedURL: nil, + error: .invalidPath + ), + ( + url: "http://example.com", + path: "path#section", + expectedURL: nil, + error: .invalidPath + ), + ( + url: "http://example.com", + path: "api?keyvalue", + expectedURL: nil, + error: .invalidQueryParameter + ), + ( + url: "http://example.com", + path: "api?key=value&&another=value", + expectedURL: nil, + error: .invalidQueryParameter + ) + ] +} + +extension URLValidatorMockData { + static let invalidProtocolURL = [ + "ftp://example.com", + "example.com", + "www.example.com", + "file://example.com" + ] + + static let invalidPathURL = [ + "http://example.com/path|with|pipes", // 유효하지 않은 특수 문자 포함 (|) + "http://example.com/path with spaces", // 유효하지 않은 공백 포함 + "http://example.com//double/slash", // 중복 슬래시 포함 + "http://example.com/path%section", // 유효하지 않은 특수 문자 (%) + "http://example.com/pathsection", // 유효하지 않은 특수 문자 (?) + "http://example.com/path{section", // 유효하지 않은 특수 문자 ({) + "http://example.com/path}section", // 유효하지 않은 특수 문자 (}) + "http://example.com/path\\section", // 유효하지 않은 특수 문자 (\\) + "http://example.com/path#section", // 유효하지 않은 특수 문자 (#) + ] + + static let invalidQueryURL = [ + "http://example.com/path?param1¶m2=value", + "http://example.com/path?param1=value&¶m2=value" + ] + + static let invalidPortURL = [ + "http://example.com:70000", + "http://example.com:7002340", + "http://example.com:234002340" + ] +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/JSONEncodingTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/JSONEncodingTest.swift new file mode 100644 index 00000000..be2ba752 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/JSONEncodingTest.swift @@ -0,0 +1,90 @@ +// +// JSONEncodingTest.swift +// NetworksTests +// +// Created by 류희재 on 11/11/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +struct MockJSONEncoding: JSONEncodingType { + public init() {} + public var jsonEncodeResult: AnyPublisher! + + func encode(_ request: URLRequest, with parameters: Encodable) -> AnyPublisher { + return jsonEncodeResult + } +} + + +class JsonEncodingTest: XCTestCase { + var cancelBag: CancelBag! + var sut: JSONEncodingType! + + override func setUpWithError() throws { + sut = JSONEncoding() + cancelBag = CancelBag() + + } + + override func tearDown() { + sut = nil + cancelBag = nil + } + + func validateEncoding( + encoder: JSONEncodingType, + requestData: URLRequest, + requestParameter: Encodable, + expectation: XCTestExpectation, + expectationError: HMHNetworkError.RequestError.ParameterEncodingError? = nil, + validationBlock: @escaping ((URLRequest) -> Void) = { _ in}) + { + encoder.encode(requestData, with: requestParameter) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error, expectationError, "Expected success, but got error: \(error)") + case .finished: + if expectationError != nil { + XCTFail("Expected error \(String(describing: expectationError)), but received success.") + } + expectation.fulfill() + } + }, receiveValue: validationBlock) + .store(in: self.cancelBag) + } +} + +///JSON 인코딩 +extension JsonEncodingTest { + func test_JSONEncoding_정상적인파라미터와URL_URLRequest반환() { + let requestData = URLRequestMockData.validRequestData + let requestParameter = EncodableParameterMockData.validParameters + + let expectation = XCTestExpectation(description: "정상적으로 JSON 인코딩에 성공했습니다!") + + for parameter in requestParameter { + validateEncoding( + encoder: JSONEncoding(), + requestData: requestData, + requestParameter: parameter, + expectation: expectation + ) { validRequest in + RequestTestHandler.checkValidHTTPBody( + expectation: expectation, + validRequest: validRequest, + expectedParameter: parameter + ) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1.0 * Double(requestParameter.count)) + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/RequestTestHandler.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/RequestTestHandler.swift new file mode 100644 index 00000000..5a2e4c60 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/RequestTestHandler.swift @@ -0,0 +1,58 @@ +// +// TestEncodingHandler.swift +// NetworksTests +// +// Created by 류희재 on 11/14/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import Foundation +import XCTest + +import Networks + +struct RequestTestHandler { + static func checkValidHTTPBody(expectation: XCTestExpectation, validRequest: URLRequest, expectedParameter: Encodable) { + do { + let validJSON = try JSONSerialization.jsonObject(with: validRequest.httpBody!, options: []) as? [String: Any] + + let expectedData = try JSONEncoder().encode(expectedParameter) + let expectedJSON = try JSONSerialization.jsonObject(with: expectedData, options: []) as? [String: Any] + + XCTAssertEqual(validJSON as NSDictionary?, expectedJSON as NSDictionary?, "파라미터가 예상 결과와 일치하지 않습니다.") + } catch { + XCTFail("JSON 처리 중 오류 발생: \(error)") + } + } + + static func checkValidQuaryItem(expectation: XCTestExpectation, validRequest: URLRequest, expectedQueryItems: [URLQueryItem]) { + guard let url = validRequest.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + XCTFail("Invalid URL or missing query parameters") + return + } + + let sortedQueryItems = queryItems.sorted(by: { $0.name < $1.name }) + let sortedExpectedQueryItems = expectedQueryItems.sorted(by: { $0.name < $1.name }) + + XCTAssertEqual(sortedQueryItems, sortedExpectedQueryItems) + } + + static func makeMockRequest( + url: String = "https://example.com", + path: String? = nil, + method: HTTPMethod = .get, + task: Task = .requestPlain, + headers: [String:String]? = ["Authorization": "Bearer token"] + ) -> MockRequest { + return MockRequest( + url: url, + path: path, + method: method, + headers: headers, + task: task, + isWithInterceptor: true + ) + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/URLEncodingTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/URLEncodingTest.swift new file mode 100644 index 00000000..79bc1029 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/ParameterEncoderTest/URLEncodingTest.swift @@ -0,0 +1,146 @@ +// +// URLEncodingTest.swift +// NetworksTests +// +// Created by 류희재 on 11/11/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +class MockURLEncoding: URLEncodingType { + public init() {} + public var urlEncodeResult: AnyPublisher! + + func encode(_ request: URLRequest, with parameters: Networks.Parameters) -> AnyPublisher { + return urlEncodeResult + } +} + +class URLEncodingTest: XCTestCase { + var cancelBag: CancelBag! + var sut: URLEncodingType! + + override func setUpWithError() throws { + sut = URLEncoding() + cancelBag = CancelBag() + + } + + override func tearDown() { + sut = nil + cancelBag = nil + } + + func validateEncoding( + encoder: URLEncodingType, + requestData: URLRequest, + requestParameter: Parameters, + expectation: XCTestExpectation, + expectationError: HMHNetworkError.RequestError.ParameterEncodingError? = nil, + validationBlock: @escaping ((URLRequest) -> Void) = { _ in}) + { + encoder.encode(requestData, with: requestParameter) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error, expectationError, "Expected success, but got error: \(error)") + expectation.fulfill() + case .finished: + if expectationError != nil { + XCTFail("Expected error \(String(describing: expectationError)), but received success.") + expectation.fulfill() + } + } + }, receiveValue: validationBlock) + .store(in: self.cancelBag) + } +} + + + +extension URLEncodingTest { + func test_URLEncoding_정상적인파라미터와URL_URLRequest반환() { + let requestData = URLRequestMockData.validRequestData + let requestParameter = ParameterValidatorMockData.validParameters + + let expectation = XCTestExpectation(description: "정상적으로 URL인코딩에 성공했습니다!") + + for parameter in requestParameter { + validateEncoding( + encoder: URLEncoding(), + requestData: requestData, + requestParameter: parameter.parameters, + expectation: expectation + ) { validRequest in + RequestTestHandler.checkValidQuaryItem( + expectation: expectation, + validRequest: validRequest, + expectedQueryItems: parameter.expectedQueryItems + ) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1.0 * Double(requestParameter.count)) + } + + func test_URLEncoding_URL이Nil일때_missingURL_에러반환() { + let requestData = URLRequestMockData.nilURLRequest + let requestParameter = ParameterValidatorMockData.validParameter + + let expectation = XCTestExpectation(description: "URL이 Nil이어서 실패했습니다!") + let expectationError: HMHNetworkError.RequestError.ParameterEncodingError = .missingURL + + validateEncoding( + encoder: URLEncoding(), + requestData: requestData, + requestParameter: requestParameter, + expectation: expectation, + expectationError: expectationError + ) + + wait(for: [expectation], timeout: 1.0) + } + + func test_파라미터가비어있을경우_emptyParameters_에러반환() { + let requestData = URLRequestMockData.validRequestData + let requestParameter = ParameterValidatorMockData.emptyParameters + + let expectation = XCTestExpectation(description: "파라미터가 비어있어서 실패했습니다!") + let expectationError: HMHNetworkError.RequestError.ParameterEncodingError = .emptyParameters + + validateEncoding( + encoder: URLEncoding(), + requestData: requestData, + requestParameter: requestParameter, + expectation: expectation, + expectationError: expectationError + ) + + wait(for: [expectation], timeout: 1.0) + } + + // 적절한 테스트 케이스가 없어서 테스트가 어려움 +// func test_비정상적인URLRequeset가주어질때_urlEncodingError에러반환() { +// let requestData = URLRequestMockData.invalidURLRequest +// let requestParameter = ParameterValidatorMockData.validParameter +// +// let expectation = XCTestExpectation(description: "urlEncodingError") +// let expectationError: HMHNetworkError.RequestError.ParameterEncodingError = .urlEncodingFailed +// +// validateEncoding( +// encoder: URLEncoding(), +// requestData: requestData, +// requestParameter: requestParameter, +// expectation: expectation, +// expectationError: expectationError +// ) +// +// wait(for: [expectation], timeout: 1.0) +// } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/RequestHandlerTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/RequestHandlerTest.swift new file mode 100644 index 00000000..7dd64562 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/RequestHandlerTest.swift @@ -0,0 +1,121 @@ +// +// RequestHandlerTest.swift +// NetworksTests +// +// Created by 류희재 on 11/14/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Networks +import Core + +class RequestHandlerTests: XCTestCase { + var cancelBag: CancelBag! + let url = "https://example.com" + let method: HTTPMethod = .get + let headers = ["Authorization": "Bearer token"] + + override func setUp() { + super.setUp() + cancelBag = CancelBag() + } + + override func tearDown() { + cancelBag = nil + super.tearDown() + } +} + +extension RequestHandlerTests { + func test_다양한URL케이스가주어질때_적절히변환() { + let testCases = URLValidatorMockData.urlTargetTypeMockData + let expectation = XCTestExpectation(description: "해당 URL에 대한 결과를 적절히 변환하였습니다!") + + for caseData in testCases { + let target = RequestTestHandler.makeMockRequest(url: caseData.url, path: caseData.path) + + let expectationError: HMHNetworkError? = caseData.error != nil ? + .invalidRequest(.invalidURL(caseData.error!)) : nil + + RequestHandler.createURLRequest(for: target) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error, expectationError, "Expected success, but got error: \(error)") + expectation.fulfill() + + case .finished: + if expectationError != nil { + XCTFail("Expected error \(String(describing: expectationError)), but received success.") + } + expectation.fulfill() + } + }, receiveValue: { validRequest in + XCTAssertEqual(validRequest.url?.absoluteString, caseData.expectedURL) + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + expectation.fulfill() + }) + .store(in: cancelBag) + } + } + + func test_다양한QueryParameters_정렬된파라미터비교() { + let testCases = ParameterValidatorMockData.validParameters + + for caseData in testCases { + let target = RequestTestHandler.makeMockRequest(task: .requestParameters(caseData.parameters)) + + let expectation = XCTestExpectation(description: "Query parameters encoded correctly for \(caseData.parameters)") + + RequestHandler.createURLRequest(for: target) + .sink(receiveCompletion: { completion in + if case .failure = completion { + XCTFail("Expected failed: \(completion)") + } + }, receiveValue: { validRequest in + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + RequestTestHandler.checkValidQuaryItem( + expectation: expectation, + validRequest: validRequest, + expectedQueryItems: caseData.expectedQueryItems + ) + expectation.fulfill() + }) + .store(in: cancelBag) + } + } + + func test_다양한JSONEncodingParameters_바디비교() { + let testCases = EncodableParameterMockData.validParameters + + for caseData in testCases { + let target = RequestTestHandler.makeMockRequest(task: .requestJSONEncodable(caseData)) + + let expectation = XCTestExpectation(description: "JSON body encoded correctly for \(caseData)") + + RequestHandler.createURLRequest(for: target) + .sink(receiveCompletion: { completion in + if case .failure = completion { + XCTFail("Expected failed: \(completion)") + } + }, receiveValue: { validRequest in + XCTAssertEqual(validRequest.url?.absoluteString, self.url) + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + RequestTestHandler.checkValidHTTPBody( + expectation: expectation, + validRequest: validRequest, + expectedParameter: caseData + ) + + expectation.fulfill() + }) + .store(in: cancelBag) + } + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/TaskTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/TaskTest.swift new file mode 100644 index 00000000..470fbd8e --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/TaskTest.swift @@ -0,0 +1,166 @@ +// +// TaskTest.swift +// NetworksTests +// +// Created by 류희재 on 11/12/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +class TaskTest: XCTestCase { + + var cancelBag: CancelBag! + let baseURL = URL(string: "https://example.com")! + let method: HTTPMethod = .get + let headers = ["Authorization": "Bearer token"] + + var mockURLEncoding: MockURLEncoding! + var mockJSONEncoding: MockJSONEncoding! + + override func setUpWithError() throws { + mockURLEncoding = MockURLEncoding() + mockJSONEncoding = MockJSONEncoding() + cancelBag = CancelBag() + + } + + override func tearDown() { + mockURLEncoding = nil + mockJSONEncoding = nil + cancelBag = nil + } + + func validTaskBuildRequest( + task: Task, + expectation: XCTestExpectation, + expectationError: HMHNetworkError.RequestError? = nil, + validationBlock: @escaping ((URLRequest) -> Void) = { _ in } + ) { + task.buildRequest(baseURL: self.baseURL, method: self.method, headers: self.headers) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error, expectationError, "Expected success, but got error: \(error)") + expectation.fulfill() + case .finished: + if expectationError != nil { + XCTFail("Expected error \(String(describing: expectationError)), but received success.") + } + expectation.fulfill() + } + }, receiveValue: validationBlock) + .store(in: self.cancelBag) + } +} + +extension TaskTest { + func test_requestPlain일때_body없이정상적인반환() { + let task = Task.requestPlain + let expectation = XCTestExpectation(description: "requestPlain일때 유효한 urlRequest를 반환합니다!") + + validTaskBuildRequest(task: task, expectation: expectation) { validRequest in + XCTAssertEqual(validRequest.url, self.baseURL) + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + XCTAssertNil(validRequest.httpBody, "Request body should be nil for plain request") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func test_requestParameters_정상적인파라미터일때_body없이정상적인반환() { + let requestParameter = ParameterValidatorMockData.validParameters + + for parameter in requestParameter { + let task = Task.requestParameters(parameter.parameters) + let expectation = XCTestExpectation(description: "requestParameter일때 \(parameter)에 대한 유효한 urlRequest를 반환합니다!") + + validTaskBuildRequest(task: task, expectation: expectation) { validRequest in + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + + RequestTestHandler.checkValidQuaryItem( + expectation: expectation, + validRequest: validRequest, + expectedQueryItems: parameter.expectedQueryItems + ) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0 * Double(requestParameter.count)) + } + } + + func test_requestParameters_파라미터인코딩에러시_에러반환() { + let requestParameter = ParameterValidatorMockData.validParameter + let expectationURLErrorList: [HMHNetworkError.RequestError.ParameterEncodingError] = [ + .emptyParameters, + .urlEncodingFailed + ] + + for expectationError in expectationURLErrorList { + mockURLEncoding.urlEncodeResult = Fail(error: expectationError).eraseToAnyPublisher() + let task = Task.requestParameters(requestParameter, urlencoder: mockURLEncoding) + + let expectation = XCTestExpectation(description: "파라미터 인코딩 시 에러가 생겨 실패했습니다!") + let expectationError = expectationError + + validTaskBuildRequest( + task: task, + expectation: expectation, + expectationError: .parameterEncodingFailed(expectationError) + ) + + wait(for: [expectation], timeout: 1.0 * Double(requestParameter.count)) + } + } + + func test_requestJSONEncodable_정상적인파라미터일때_httpbody를포함한_정상적인반환() { + let requestEncodableParameter = EncodableParameterMockData.validParameters + + for parameter in requestEncodableParameter { + let task = Task.requestJSONEncodable(parameter) + let expectation = XCTestExpectation(description: "requestJSONEncodable일때 \(parameter)에 대한 유효한 urlRequest를 반환합니다!") + + validTaskBuildRequest(task: task, expectation: expectation) { validRequest in + XCTAssertEqual(validRequest.url, self.baseURL) + XCTAssertEqual(validRequest.httpMethod, self.method.rawValue) + XCTAssertEqual(validRequest.allHTTPHeaderFields, self.headers) + RequestTestHandler.checkValidHTTPBody( + expectation: expectation, + validRequest: validRequest, + expectedParameter: parameter + ) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0 * Double(requestEncodableParameter.count)) + } + } + + func test_requestJSONEncodable_파라미터인코딩에러시_에러반환() { + let requestParameter = ParameterValidatorMockData.validParameter + let expectationJSONError = HMHNetworkError.RequestError.ParameterEncodingError.jsonEncodingFailed + + mockURLEncoding.urlEncodeResult = Fail(error: expectationJSONError).eraseToAnyPublisher() + let task = Task.requestParameters(requestParameter, urlencoder: mockURLEncoding) + + let expectation = XCTestExpectation(description: "파라미터 인코딩 시 에러가 생겨 실패했습니다!") + let expectationError = expectationJSONError + + validTaskBuildRequest( + task: task, + expectation: expectation, + expectationError: .parameterEncodingFailed(expectationError) + ) + + wait(for: [expectation], timeout: 1.0) + + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLRequestTargetTypeTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLRequestTargetTypeTest.swift new file mode 100644 index 00000000..31bcfda2 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLRequestTargetTypeTest.swift @@ -0,0 +1,124 @@ +// +// URLRequestTargetTypeTest.swift +// NetworksTests +// +// Created by 류희재 on 11/12/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +struct MockRequest: URLRequestTargetType { + var url: String + var path: String? + var method: HTTPMethod + var headers: [String : String]? + var task: Task + var isWithInterceptor: Bool +} + +class URLRequestTargetTypeTest: XCTestCase { + + var cancelBag: CancelBag! + let method: HTTPMethod = .get + let headers = ["Authorization": "Bearer token"] + + override func setUpWithError() throws { + cancelBag = CancelBag() + + } + + override func tearDown() { + cancelBag = nil + } +} + +extension URLRequestTargetTypeTest { + func test_asURLRequest_다양한케이스URL이주어질때_적절히변환() { + let urlCases = URLValidatorMockData.urlTargetTypeMockData + for caseData in urlCases { + let target = RequestTestHandler.makeMockRequest(url: caseData.url, path: caseData.path) + let expectation = XCTestExpectation(description: "해당 URL에 대한 결과를 적절히 변환하였습니다!") + + let expectationError: HMHNetworkError.RequestError? = caseData.error != nil ? + .invalidURL(caseData.error!) : nil + + target.asURLRequest() + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error, expectationError, "Expected success, but got error: \(error)") + expectation.fulfill() + case .finished: + if caseData.error != nil { + XCTFail("Expected error \(String(describing: caseData.error)), but received success.") + } + expectation.fulfill() + } + }, receiveValue: { request in + XCTAssertEqual(request.url?.absoluteString, caseData.expectedURL) + XCTAssertEqual(request.httpMethod, self.method.rawValue) + XCTAssertEqual(request.allHTTPHeaderFields, self.headers) + expectation.fulfill() + }) + .store(in: cancelBag) + } + } + + func test_asURLRequest_다양한QueryParameters_정렬된파라미터비교() { + let parameterCases = ParameterValidatorMockData.validParameters + + for caseData in parameterCases { + let target = RequestTestHandler.makeMockRequest(task: .requestParameters(caseData.parameters)) + + let expectation = XCTestExpectation(description: "쿼리파라미터가 성공적으로 인코딩되었습니다! \(caseData.parameters)") + + target.asURLRequest() + .sink(receiveCompletion: { completion in + if case .failure = completion { + XCTFail("Expected failed: \(completion)") + } + }, receiveValue: { validRequest in + RequestTestHandler.checkValidQuaryItem( + expectation: expectation, + validRequest: validRequest, + expectedQueryItems: caseData.expectedQueryItems + ) + expectation.fulfill() + }) + .store(in: cancelBag) + } + } + + func test_asURLRequest_다양한JSONEncodingParameters_바디비교() { + let parameterCases = EncodableParameterMockData.validParameters + + for caseData in parameterCases { + let target = RequestTestHandler.makeMockRequest(task: .requestJSONEncodable(caseData)) + + let expectation = XCTestExpectation(description: "JSON body가 성공적으로 인코딩되었습니다! \(caseData)") + + target.asURLRequest() + .sink(receiveCompletion: { completion in + if case .failure = completion { + XCTFail("Expected failed: \(completion)") + } + }, receiveValue: { validRequest in + RequestTestHandler.checkValidHTTPBody( + expectation: expectation, + validRequest: validRequest, + expectedParameter: caseData + ) + + expectation.fulfill() + }) + .store(in: cancelBag) + } + } +} + + diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLValidatorTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLValidatorTest.swift new file mode 100644 index 00000000..cb06f2f3 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/RequestTest/URLRequestTargetTypeTest/URLValidatorTest.swift @@ -0,0 +1,69 @@ +// +// URLValidatorTest.swift +// NetworksTests +// +// Created by 류희재 on 11/12/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +class URLValidatorTest: XCTestCase { + + var cancelBag: CancelBag! + + override func setUpWithError() throws { + cancelBag = CancelBag() + } + + override func tearDown() { + cancelBag = nil + } + + func test_빈url이주어질때_emptyurlString에러반환() { + let result = URLValidator.validateURL("") + XCTAssertEqual(result, .failure(.emptyurlString)) + } + + func test_잘못된프로토콜이주어질때_invalidProtocol에러반환() { + for url in URLValidatorMockData.invalidProtocolURL { + let result = URLValidator.validateURL(url) + XCTAssertEqual(result, .failure(.invalidProtocol)) + } + } + + func test_잘못된포트번호가주어질때_invalidPort에러반환() { + for url in URLValidatorMockData.invalidPortURL { + let result = URLValidator.validateURL(url) + XCTAssertEqual(result, .failure(.invalidPort)) + } + + func test_잘못된경로가주어질때_invalidPath에러반환() { + for url in URLValidatorMockData.invalidPathURL { + let result = URLValidator.validateURL(url) + XCTAssertEqual(result, .failure(.invalidPath)) + } + } + + func test_잘못된쿼리파라미터가주어질때_invalidQueryParameter에러반환() { + for url in URLValidatorMockData.invalidQueryURL { + let result = URLValidator.validateURL(url) + XCTAssertEqual(result, .failure(.invalidQueryParameter)) + } + } + + func test_정상적인URL이주어질때_정상적인반환() { + let result = URLValidator.validateURL("http://example.com/path?param1=value¶m2=value") + switch result { + case .success(let url): + XCTAssertEqual(url.absoluteString, "http://example.com/path?param1=value¶m2=value") + case .failure(let error): + XCTFail("Expected valid URL but got failure \(error)") + } + } + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/ResponseTest/ErrorHandlerTest.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/ResponseTest/ErrorHandlerTest.swift new file mode 100644 index 00000000..8e8f7f15 --- /dev/null +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/ResponseTest/ErrorHandlerTest.swift @@ -0,0 +1,149 @@ +// +// ErrorHandlerTest.swift +// NetworksTests +// +// Created by 류희재 on 11/19/24. +// Copyright © 2024 HMH-iOS. All rights reserved. +// + +import XCTest +import Combine + +import Core +import Networks + +class ErrorHandlerTest: XCTestCase { + var cancelBag: CancelBag! + var mockURLReqeust = URLRequestMockData.validRequestData + var mockRequestTarget = MockURLRequestTarget.mockTargetType + var mockParameter = EncodableParameterMockData.validEncodableParameter + + override func setUpWithError() throws { + cancelBag = CancelBag() + } + + override func tearDown() { + cancelBag = nil + } +} + +extension ErrorHandlerTest { + func test_파라미터에러가주어질때_적절한에러로변환하는가() { + let parameterEncodingError: [HMHNetworkError.RequestError.ParameterEncodingError] = [ + .emptyParameters, + .jsonEncodingFailed, + .missingURL, + .urlEncodingFailed, + .unknownErr + ] + + parameterEncodingError.forEach { + let error = ErrorHandler.handleParameterEncodingError(mockURLReqeust, mockParameter, error: $0) + XCTAssertEqual(error, .parameterEncodingFailed($0)) + } + } + + func test_url에에러가주어질때_적절한에러로변환하는가() { + let inValidURLError: [HMHNetworkError.RequestError.URLValidationError] = [ + .emptyurlString, + .invalidPath, + .invalidPort, + .invalidProtocol, + .invalidQueryParameter + ] + + inValidURLError.forEach { invalidError in + + let expectation = XCTestExpectation(description: "\(invalidError)에 대한 적절한 에러를 반환합니다!") + + ErrorHandler.handleInvalidURLError(mockRequestTarget, error: invalidError) + .sink(receiveCompletion: { completion in + if case let .failure(error) = completion { + XCTAssertEqual(error, .invalidURL(invalidError), "Expected success, but got error: \(error)") + expectation.fulfill() + } + }, receiveValue: { _ in }) + .store(in: cancelBag) + } + } + + func test_응답이오지않았을때_적절한에러로변환하는가() { + let noResponseError: [HMHNetworkError.ResponseError] = [ + .cancelled, + .unhandled, + .unknown + ] + + noResponseError.forEach { + let error = ErrorHandler.handleNoResponseError(mockRequestTarget, error: $0) + XCTAssertEqual(error, .invalidResponse($0)) + } + + } + + func test_유효하지않은응답에러가주어질때_ErrorResponse로인코딩이되는경우_적절한에러로변환하는가() { + let mockResponse = NetworkResponseMockData.responseWith( + statusCode: 404, + data: NetworkResponseMockData.validErrorData + ) + + let error = ErrorHandler.handleInvalidResponse(response: mockResponse) + + if case let .invalidResponse(responseError) = error, + case let .invalidStatusCode(code, message) = responseError { + XCTAssertEqual(code, 404) + XCTAssertEqual(message, "테스트를 위해서 사용된 에러메세지입니다!") + } else { + XCTFail("Expected invalidResponse with invalidStatusCode") + } + } + + func test_유효하지않은응답에러가주어질때_ErrorResponse로인코딩이되지않는경우_적절한에러로변환하는가() { + let mockResponse = NetworkResponseMockData.responseWith( + statusCode: 404, + data: NetworkResponseMockData.invalidErrorData + ) + + let error = ErrorHandler.handleInvalidResponse(response: mockResponse) + + if case let .invalidResponse(responseError) = error, + case let .invalidStatusCode(code, _) = responseError { + XCTAssertEqual(code, 404) + } else { + XCTFail("Expected invalidResponse with invalidStatusCode") + } + } + + func test_유효하지않은응답에러가주어질때_데이터가없는경우_적절한에러로변환하는가() { + let mockResponse = NetworkResponseMockData.responseWith( + statusCode: 404, + data: nil + ) + + let error = ErrorHandler.handleInvalidResponse(response: mockResponse) + + if case let .invalidResponse(responseError) = error, + case .noResponseData = responseError { + XCTAssertTrue(true) + } else { + XCTFail("Expected invalidResponse with noResponseData") + } + } + + func test_요청횟수가초과에러가주어질때_적절한에러로변환하는가() { + let error = ErrorHandler.handleRetryLimitExceeded() + XCTAssertEqual(error, .retryLimitExceeded) + } + + func test_디코딩에러가주어질때_적절한에러로변환하는가() { + let decodingError: [HMHNetworkError.DecodeError] = [ + .dataIsNil, + .decodingFailed + ] + + decodingError.forEach { + let error = ErrorHandler.handleDecodingError(data: Data(), decodingType: MockResult.self, error: $0) + XCTAssertEqual(error, .decodingFailed($0)) + } + } +} diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/AuthServiceTests.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/AuthServiceTests.swift index 273c2b5e..89ced5ba 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/AuthServiceTests.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/AuthServiceTests.swift @@ -15,13 +15,11 @@ import Core final class AuthServiceTests: XCTestCase { var sut: AuthServiceType! - var mockRequestHandler: RequestHandling! var cancelBag: CancelBag! override func setUp() { cancelBag = CancelBag() - mockRequestHandler = RequestHandler() - sut = AuthService(requestHandler: mockRequestHandler) + sut = AuthService() UserManager.shared.accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDU1Mjk0OSwiZXhwIjoxNzMwNzI1NzQ5fQ.FdkQxEz_aNOGRt1dIIIf5FyrDQN9IdSJghBb-fXofPtpsno2X54V0PVCYHF2Kt7FgFXZirsKKOgpEoNqdt14Fw" UserManager.shared.refreshToken = "lNZIf_66imXVXmfWFwKz3QYRRUb-BdOUAAAAAgopyWAAAAGS7OcbNd0Jz_1t7hqp" @@ -29,7 +27,6 @@ final class AuthServiceTests: XCTestCase { override func tearDown() { cancelBag = nil - mockRequestHandler = nil sut = nil } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/ChallengeServiceTests.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/ChallengeServiceTests.swift index 65b0873a..2ae19514 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/ChallengeServiceTests.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/ChallengeServiceTests.swift @@ -15,21 +15,18 @@ import Core final class ChallengeServiceTests: XCTestCase { var sut: ChallengeServiceType! - var mockRequestHandler: RequestHandling! var cancelBag: CancelBag! override func setUp() { cancelBag = CancelBag() - mockRequestHandler = RequestHandler() - sut = ChallengeService(requestHandler: mockRequestHandler) + sut = ChallengeService() - UserManager.shared.accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzMwMjcyMDkyLCJleHAiOjE3MzA0NDQ4OTJ9.FULSF-b-cu4iH25ld_EgL99g310XT1uTHcyyebBgxxpYERXXk19Mb-TyfaeDEWUMpkC6vjrjWz5yPc27fPbPTQ" - UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzMwMjcyMDkyLCJleHAiOjE3MzE0ODE2OTJ9.9SrHLvCCbFVt_p6GZvh0P91CgLSZfH3VgFDH2HZHiVHXdjC0O_4OUiv9wZI4Hmf3BwSer8awR8ilOTsKIODS6A" + UserManager.shared.accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMxMTM0NDYyfQ.IUslL_OzE-NshP1cPeyLQpU2w3fsQAQhfhQIJzlmjdkw4EipmDhdHCXtY8F8IyTi2fig8IoMyY0n4XwWvioLt" + UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMyMTcxMjYyfQ.QwsXx96ig2BMpXsobji-ZkseXO4aHrzXLxKmKeNMVABjpb6rfaLQDJ1Rh4ZgKWff003d3XJqN9582ZbsxJPjeA" } override func tearDown() { cancelBag = nil - mockRequestHandler = nil sut = nil } @@ -37,7 +34,7 @@ final class ChallengeServiceTests: XCTestCase { let expectation = XCTestExpectation() - sut.getdailyChallenge() + sut.getDailyChallenge() .sink { completion in if case let .failure(err) = completion { XCTFail(err.localizedDescription)} } receiveValue: { roomDetails in @@ -53,7 +50,7 @@ final class ChallengeServiceTests: XCTestCase { let expectation = XCTestExpectation() - sut.getSuccesChallenge() + sut.postSuccesChallenge() .sink { completion in if case let .failure(err) = completion { XCTFail(err.localizedDescription)} } receiveValue: { roomDetails in diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/PointServiceTests.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/PointServiceTests.swift index f4e22508..08855c31 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/PointServiceTests.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/PointServiceTests.swift @@ -15,21 +15,18 @@ import Core final class PointServiceTests: XCTestCase { var sut: PointServiceType! - var mockRequestHandler: RequestHandling! var cancelBag: CancelBag! override func setUp() { cancelBag = CancelBag() - mockRequestHandler = RequestHandler() - sut = PointService(requestHandler: mockRequestHandler) + sut = PointService() - UserManager.shared.accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDU1Mjk0OSwiZXhwIjoxNzMwNzI1NzQ5fQ.FdkQxEz_aNOGRt1dIIIf5FyrDQN9IdSJghBb-fXofPtpsno2X54V0PVCYHF2Kt7FgFXZirsKKOgpEoNqdt14Fw" - UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDU1Mjk0OSwiZXhwIjoxNzMxNzYyNTQ5fQ.uBfqSwIl7ypuM0ZWTUiOASEM6__D2heKPc2NkgIFDmg9lcNBi2PoKxvq8L9NRhTbsEFdYDMKVOgRNWOOZc4RxQ" + UserManager.shared.accessToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMxMTM0NDYyfQ.IUslL_OzE-NshP1cPeyLQpU2w3fsQAQhfhQIJzlmjdkw4EipmDhdHCXtY8F8IyTi2fig8IoMyY0n4XwWvioLtw" + UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMyMTcxMjYyfQ.QwsXx96ig2BMpXsobji-ZkseXO4aHrzXLxKmKeNMVABjpb6rfaLQDJ1Rh4ZgKWff003d3XJqN9582ZbsxJPjeA" } override func tearDown() { cancelBag = nil - mockRequestHandler = nil sut = nil } diff --git a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/UserServiceTests.swift b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/UserServiceTests.swift index dcd87840..f25651ed 100644 --- a/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/UserServiceTests.swift +++ b/HMH_Tuist_iOS/Projects/Modules/Networks/Tests/Sources/Service/UserServiceTests.swift @@ -15,21 +15,18 @@ import Core final class UserServiceTests: XCTestCase { var sut: UserServiceType! - var mockRequestHandler: RequestHandling! var cancelBag: CancelBag! override func setUp() { cancelBag = CancelBag() - mockRequestHandler = RequestHandler() - sut = UserService(requestHandler: mockRequestHandler) + sut = UserService() - UserManager.shared.accessToken = "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzMwMjcyMDkyLCJleHAiOjE3MzA0NDQ4OTJ9.FULSF-b-cu4iH25ld_EgL99g310XT1uTHcyyebBgxxpYERXXk19Mb-TyfaeDEWUMpkC6vjrjWz5yPc27fPbPTQ" - UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiaWF0IjoxNzMwMjcyMDkyLCJleHAiOjE3MzE0ODE2OTJ9.9SrHLvCCbFVt_p6GZvh0P91CgLSZfH3VgFDH2HZHiVHXdjC0O_4OUiv9wZI4Hmf3BwSer8awR8ilOTsKIODS6A" + UserManager.shared.accessToken = "eeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMxMTM0NDYyfQ.IUslL_OzE-NshP1cPeyLQpU2w3fsQAQhfhQIJzlmjdkw4EipmDhdHCXtY8F8IyTi2fig8IoMyY0n4XwWvioLtw" + UserManager.shared.refreshToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NSIsImlhdCI6MTczMDk2MTY2MiwiZXhwIjoxNzMyMTcxMjYyfQ.QwsXx96ig2BMpXsobji-ZkseXO4aHrzXLxKmKeNMVABjpb6rfaLQDJ1Rh4ZgKWff003d3XJqN9582ZbsxJPjeA" } override func tearDown() { cancelBag = nil - mockRequestHandler = nil sut = nil } diff --git a/HMH_Tuist_iOS/Tuist/Dependencies.swift b/HMH_Tuist_iOS/Tuist/Dependencies.swift index a8249ff4..9c396abd 100644 --- a/HMH_Tuist_iOS/Tuist/Dependencies.swift +++ b/HMH_Tuist_iOS/Tuist/Dependencies.swift @@ -16,7 +16,7 @@ let spm = SwiftPackageManagerDependencies([ .remote(url: "https://github.com/kishikawakatsumi/KeychainAccess", requirement: .upToNextMajor(from: "4.2.2")) ], baseSettings: Settings.settings( - configurations: XCConfig.framework + configurations: XCConfig.configurations )) let dependencies = Dependencies( diff --git a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/FeatureTargets.swift b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/FeatureTargets.swift index 01b4756c..c48c1601 100644 --- a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/FeatureTargets.swift +++ b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/FeatureTargets.swift @@ -9,19 +9,44 @@ import Foundation import ProjectDescription public enum FeatureTarget { - case app // iOSApp + case app // iOSApp case interface // Feature Interface case dynamicFramework case staticFramework case unitTest // Unit Test case demo // Feature Excutable Test - + public var hasFramework: Bool { switch self { case .dynamicFramework, .staticFramework: return true default: return false } } - public var hasDynamicFramework: Bool { return self == .dynamicFramework } + + public var product: Product { + switch self { + case .app, .demo: + return .app + case .interface, .dynamicFramework: + return .framework + case .staticFramework: + return .staticFramework + case .unitTest: + return .unitTests + } + } + + public var sources: SourceFilesList { + switch self { + case .app, .staticFramework, .dynamicFramework: + return .sources + case .interface: + return .interface + case .unitTest: + return .unitTests + case .demo: + return .demoSources + } + } } diff --git a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift new file mode 100644 index 00000000..8726a3e3 --- /dev/null +++ b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -0,0 +1,32 @@ +// +// InfoPlist+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 류희재 on 11/20/24. +// + +import Foundation +import ProjectDescription +import EnvPlugin + +struct InfoPlistProvider { + static func forApp(name: String) -> InfoPlist { + var infoPlist = name.contains("Demo") ? Project.demoInfoPlist : Project.appInfoPlist + + switch name { + case "DeviceActivityMonitor": + infoPlist = Project.deviceActivityMonitorInfoPlist + case "HMHDeviceActivityReport": + infoPlist = Project.hmhDeviceActivityReportInfoPlist + case "ShieldActionExtension": + infoPlist = Project.shieldActionExtensionInfoPlist + case "ShieldConfigureExtension": + infoPlist = Project.shieldConfigureExtensionInfoPlist + default: + break + } + + return .extendingDefault(with: infoPlist) + + } +} diff --git a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index c5700e63..ed418d27 100644 --- a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -11,7 +11,6 @@ public extension Project { /// - Parameters: /// - name: 프로젝트 이름 (모듈 이름) /// - targets: 빌드할 타겟의 대상 - /// - organizationName: organization 이름 /// - packages:SPM의 package /// - internalDependencies: 내부 의존성 /// - externalDependencies: 외부 의존성 @@ -21,7 +20,6 @@ public extension Project { static func makeModule( name: String, targets: Set = Set([.staticFramework, .unitTest, .demo]), - organizationName: String = "HMH-iOS", packages: [Package] = [], internalDependencies: [TargetDependency] = [], externalDependencies: [TargetDependency] = [], @@ -30,222 +28,23 @@ public extension Project { hasResources: Bool = false ) -> Project { let configurationName: ConfigurationName = "Development" - let hasDynamicFramework = targets.contains(.dynamicFramework) - let baseSetting: SettingsDictionary = [:] + var dependencies: [TargetDependency] = internalDependencies + externalDependencies + interfaceDependencies - var projectTargets: [Target] = [] - var schemes: [Scheme] = [] - - // MARK: - APP - - if targets.contains(.app) { - let bundleSuffix = name.contains("Demo") ? "test" : "release" - var infoPlist = name.contains("Demo") ? Project.demoInfoPlist : Project.appInfoPlist - - switch name { - case "DeviceActivityMonitor": - infoPlist = Project.deviceActivityMonitorInfoPlist - case "HMHDeviceActivityReport": - infoPlist = Project.hmhDeviceActivityReportInfoPlist - case "ShieldActionExtension": - infoPlist = Project.shieldActionExtensionInfoPlist - case "ShieldConfigureExtension": - infoPlist = Project.shieldConfigureExtensionInfoPlist - default: - break - } - - let setting = baseSetting - - let target = Target( - name: name, - platform: env.platform, - product: .app, - bundleId: "\(env.bundlePrefix).\(bundleSuffix)", - deploymentTarget: env.deploymentTarget, - //infoPlist에 .extendingDefault로 Info.plsit에 추가 내용을 넣어준 이유는 tuist에서 .default 만들어주는 Info.plist는 앱을 실행할 때 화면이 어딘가 나사가 빠진상태로 실행되기 때문입니다. - infoPlist: .extendingDefault(with: infoPlist), - sources: .sources, - // 터미널 명령어랑 비슷한듯? 일단 바쁘니까 나중에 정리해보도록 하자ㅏ https://www.daleseo.com/glob-patterns/#google_vignette - resources: [.glob(pattern: "Resources/**", excluding: [])], - //entitlement: 주로 iOS 애플리케이션에서 특정 기능이나 권한을 활성화하기 위해 사용하는 설정 파일 - entitlements: "\(name).entitlements", - dependencies: [ - internalDependencies, - externalDependencies - ].flatMap { $0 }, - settings: .settings(base: setting, configurations: XCConfig.project) - ) - projectTargets.append(target) - } - - //MARK: - Feature Interface - - if targets.contains(.interface) { - let setting = baseSetting - let target = Target( - name: "\(name)Interface", - platform: env.platform, - product: .framework, - bundleId: "\(env.bundlePrefix).\(name)Interface", - deploymentTarget: env.deploymentTarget, - infoPlist: .default, - sources: .interface, - dependencies: interfaceDependencies, - settings: .settings(base: setting,configurations: XCConfig.framework) - ) - - projectTargets.append(target) - } - - // MARK: - Framework - - if targets.contains(where: { $0.hasFramework }) { - let deps: [TargetDependency] = targets.contains(.interface) - ? [.target(name: "\(name)Interface")] - : [] - let settings = baseSetting - - let target = Target( - name: name, - platform: env.platform, - product: hasDynamicFramework ? .framework : .staticFramework, - bundleId: "\(env.bundlePrefix).\(name)", - deploymentTarget: env.deploymentTarget, - infoPlist: .default, - sources: .sources, - resources: hasResources ? [.glob(pattern: "Resources/**", excluding: [])] : [], - dependencies: deps + internalDependencies + externalDependencies, - settings: .settings(base: settings, configurations: XCConfig.framework) - ) - - projectTargets.append(target) - } - - //MARK: - Feature DemoApp - - if targets.contains(.demo) { - let deps: [TargetDependency] = [.target(name:name)] - let setting = baseSetting - - let target = Target( - name: "\(name)Demo", - platform: env.platform, - product: .app, - bundleId: "\(env.bundlePrefix).\(name)Demo", - deploymentTarget: env.deploymentTarget, - infoPlist: .extendingDefault(with: Project.demoInfoPlist), - sources: .demoSources, - resources: [.glob(pattern: "Demo/Resources/**", excluding: ["Demo/Resources/dummy.txt"])], - dependencies: deps, - settings: .settings(base: setting, configurations: XCConfig.demo) - ) - projectTargets.append(target) - } - - //MARK: - Unit Tests - - if targets.contains(.unitTest) { - let deps: [TargetDependency] = [.target(name: name)] - - let target = Target( - name: "\(name)Tests", - platform: env.platform, - product: .unitTests, - bundleId: "\(env.bundlePrefix).\(name)Tests", - deploymentTarget: env.deploymentTarget, - infoPlist: .default, - sources: .unitTests, - dependencies: deps, - settings: .settings(base: SettingsDictionary().setCodeSignManual(), configurations: XCConfig.tests) - ) - - projectTargets.append(target) - } - - let additionalSchemes = targets.contains(.demo) ? - [ - Scheme.makeScheme(configs: configurationName, name: name), - Scheme.makeDemoScheme(configs: configurationName, name: name) - ] - : [ - Scheme.makeScheme(configs: configurationName, name: name) - ] - - schemes += additionalSchemes - - - var scheme = targets.contains(.app) ? appSchemes : schemes - - if name.contains("Demo") { - let testAppScheme = Scheme.makeScheme(configs: "QA", name: name) - scheme.append(testAppScheme) - } + var projectTargets: [Target] = TargetHandler.makeProjectTargets( + name: name, + hasResources: hasResources, + with: dependencies, + targets: targets + ) + var projcetScheme: [Scheme] = SchemeProvider.makeProjectScheme(targets: targets, name: name) return Project( name: name, organizationName: env.workspaceName, packages: packages, - settings: .settings(configurations: XCConfig.project), + settings: .settings(configurations: XCConfig.configurations), targets: projectTargets, - schemes: schemes + schemes: projcetScheme ) } } - -extension Project { - static let appSchemes: [Scheme] = [ - // PROD API, debug scheme : 실제 프로덕트 BaseURL을 사용하는 debug scheme - .init( - name: "\(env.workspaceName)-DEV", - shared: true, - buildAction: .buildAction(targets: ["\(env.workspaceName)"]), - testAction: .targets( - ["\(env.workspaceName)Tests", "\(env.workspaceName)UITests"], - configuration: "Development", - options: .options(coverage: true, codeCoverageTargets: ["\(env.workspaceName)"]) - ), - runAction: .runAction(configuration: "Development"), - archiveAction: .archiveAction(configuration: "Development"), - profileAction: .profileAction(configuration: "Development"), - analyzeAction: .analyzeAction(configuration: "Development") - ), - // Test API, debug scheme : 테스트 BaseURL을 사용하는 debug scheme - .init( - name: "\(env.workspaceName)-Test", - shared: true, - buildAction: .buildAction(targets: ["\(env.workspaceName)"]), - testAction: .targets( - ["\(env.workspaceName)Tests", "\(env.workspaceName)UITests"], - configuration: "Test", - options: .options(coverage: true, codeCoverageTargets: ["\(env.workspaceName)"]) - ), - runAction: .runAction(configuration: "Test"), - archiveAction: .archiveAction(configuration: "Test"), - profileAction: .profileAction(configuration: "Test"), - analyzeAction: .analyzeAction(configuration: "Test") - ), - // Test API, release scheme : 테스트 BaseURL을 사용하는 release scheme - .init( - name: "\(env.workspaceName)-QA", - shared: true, - buildAction: .buildAction(targets: ["\(env.workspaceName)"]), - runAction: .runAction(configuration: "QA"), - archiveAction: .archiveAction(configuration: "QA"), - profileAction: .profileAction(configuration: "QA"), - analyzeAction: .analyzeAction(configuration: "QA") - ), - // PROD API, release scheme : 실제 프로덕트 BaseURL을 사용하는 release scheme - .init( - name: "\(env.workspaceName)-PROD", - shared: true, - buildAction: .buildAction(targets: ["\(env.workspaceName)"]), - runAction: .runAction(configuration: "PROD"), - archiveAction: .archiveAction(configuration: "PROD"), - profileAction: .profileAction(configuration: "PROD"), - analyzeAction: .analyzeAction(configuration: "PROD") - ), - // Test API, debug scheme, Demo App Target - .makeDemoAppTestScheme() - ] -} diff --git a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Scheme+Template.swift b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Scheme+Template.swift index a5855991..42dc09b1 100644 --- a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Scheme+Template.swift +++ b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Scheme+Template.swift @@ -8,64 +8,87 @@ import ProjectDescription import EnvPlugin -extension Scheme { - /// Scheme 생성하는 method - /// 어떤 타겟을 빌드할 것인지, 어떤 테스트를 실행할 것인지 또한 어떤 환경에서 빌드할 것인지 설정 - /// - /// DEV : 실제 프로덕트 BaseURL을 사용하는 debug scheme - /// TEST : 테스트 BaseURL을 사용하는 debug scheme - /// QA : 테스트 BaseURL을 사용하는 release scheme - /// RELEASE : 실제 프로덕트 BaseURL을 사용하는 release scheme +struct SchemeProvider { + static func makeProjectScheme(targets: Set, name: String) -> [Scheme] { + if targets.contains(.app) { + return Scheme.appSchemes + } else { + var scheme: [Scheme] = [Scheme.makeScheme(name: name)] + if targets.contains(.demo) { scheme.append(Scheme.makeDemoScheme(name: name))} + return scheme + } + } +} - static func makeScheme(configs: ConfigurationName, name: String) -> Scheme { // 일반앱 - return Scheme( - name: name, +extension Scheme { + static let appSchemes: [Scheme] = [ + .init( + name: "\(env.workspaceName)-DEV", shared: true, - buildAction: .buildAction(targets: ["\(name)"]), + buildAction: .buildAction(targets: ["\(env.workspaceName)"]), testAction: .targets( - ["\(name)Tests"], - configuration: configs, - options: .options(coverage: true, codeCoverageTargets: ["\(name)"]) + ["\(env.workspaceName)Tests", "\(env.workspaceName)UITests"], + configuration: "Development", + options: .options(coverage: true, codeCoverageTargets: ["\(env.workspaceName)"]) ), - runAction: .runAction(configuration: configs), - archiveAction: .archiveAction(configuration: configs), - profileAction: .profileAction(configuration: configs), - analyzeAction: .analyzeAction(configuration: configs) - ) - } - - static func makeDemoScheme(configs: ConfigurationName, name: String) -> Scheme { // 데모앱 + runAction: .runAction(configuration: "Development"), + archiveAction: .archiveAction(configuration: "Development"), + profileAction: .profileAction(configuration: "Development"), + analyzeAction: .analyzeAction(configuration: "Development") + ), + .init( + name: "\(env.workspaceName)-QA", + shared: true, + buildAction: .buildAction(targets: ["\(env.workspaceName)"]), + runAction: .runAction(configuration: "QA"), + archiveAction: .archiveAction(configuration: "QA"), + profileAction: .profileAction(configuration: "QA"), + analyzeAction: .analyzeAction(configuration: "QA") + ), + .init( + name: "\(env.workspaceName)-PROD", + shared: true, + buildAction: .buildAction(targets: ["\(env.workspaceName)"]), + runAction: .runAction(configuration: "PROD"), + archiveAction: .archiveAction(configuration: "PROD"), + profileAction: .profileAction(configuration: "PROD"), + analyzeAction: .analyzeAction(configuration: "PROD") + ), + ] + + // makeDemoScheme은 개발환경에서 release로 (demo앱이기때문에!) + static func makeDemoScheme(name: String) -> Scheme { // 데모앱 return Scheme( name: "\(name)Demo", shared: true, buildAction: .buildAction(targets: ["\(name)Demo"]), testAction: .targets( ["\(name)Tests"], - configuration: configs, + configuration: "QA", options: .options(coverage: true, codeCoverageTargets: ["\(name)Demo"]) ), - runAction: .runAction(configuration: configs), - archiveAction: .archiveAction(configuration: configs), - profileAction: .profileAction(configuration: configs), - analyzeAction: .analyzeAction(configuration: configs) + runAction: .runAction(configuration: "QA"), + archiveAction: .archiveAction(configuration: "QA"), + profileAction: .profileAction(configuration: "QA"), + analyzeAction: .analyzeAction(configuration: "QA") ) } - - static func makeDemoAppTestScheme() -> Scheme { // 데모테스트앱 - let targetName = "\(env.workspaceName)-Demo" + + // makeScheme은 개발환경에서 debug (그냥 개발 빌드이기 때문에) + static func makeScheme(name: String) -> Scheme { // 일반앱 return Scheme( - name: "\(targetName)-Test", - shared: true, - buildAction: .buildAction(targets: ["\(targetName)"]), - testAction: .targets( - ["\(targetName)Tests"], - configuration: "Test", - options: .options(coverage: true, codeCoverageTargets: ["\(targetName)"]) - ), - runAction: .runAction(configuration: "Test"), - archiveAction: .archiveAction(configuration: "Test"), - profileAction: .profileAction(configuration: "Test"), - analyzeAction: .analyzeAction(configuration: "Test") + name: name, + shared: true, + buildAction: .buildAction(targets: ["\(name)"]), + testAction: .targets( + ["\(name)Tests"], + configuration: "Development", + options: .options(coverage: true, codeCoverageTargets: ["\(name)"]) + ), + runAction: .runAction(configuration: "Development"), + archiveAction: .archiveAction(configuration: "Development"), + profileAction: .profileAction(configuration: "Development"), + analyzeAction: .analyzeAction(configuration: "Development") ) } } diff --git a/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift new file mode 100644 index 00000000..ad3fb463 --- /dev/null +++ b/HMH_Tuist_iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift @@ -0,0 +1,142 @@ +// +// Target+Templates.swift +// ProjectDescriptionHelpers +// +// Created by 류희재 on 11/20/24. +// + +import Foundation +import ProjectDescription + +import EnvPlugin + +/// 빌드할 파일들의 집합을 만들어줌 +struct TargetHandler { + static func makeTarget( + targetType: FeatureTarget, + name: String, + platform: Platform = env.platform, + bundleID: String, + deploymentTarget: DeploymentTarget = env.deploymentTarget, + infoPlist: InfoPlist = .default, + resources: ResourceFileElements? = nil, + entitlements: Entitlements? = nil, + dependencies: [TargetDependency] = [] + ) -> Target { + .init( + name: name, + platform: platform, + product: targetType.product, + bundleId: bundleID, + deploymentTarget: deploymentTarget, + infoPlist: infoPlist, + sources: targetType.sources, + resources: resources, + entitlements: entitlements, + dependencies: dependencies + ) + } + + static func makeProjectTargets( + name: String, + hasResources: Bool, + with dependencies: [TargetDependency], + targets: Set + ) -> [Target] { + var projectTargets: [Target] = [] + targets.forEach { targetType in + let target = { + switch targetType { + case .app: + return TargetHandler.makeAppTarget( + name: name, + dependencies: dependencies + ) + case .interface: + return TargetHandler.makeInterfaceTarget( + name: name, + dependencies: dependencies + ) + case .staticFramework, .dynamicFramework: + return TargetHandler.makeFrameworkTarget( + targetType: targetType, + name: name, + hasResources: hasResources, + dependencies: dependencies + ) + case .unitTest: + return TargetHandler.makeUnitTestTarget(name: name) + case .demo: + return TargetHandler.makeDemoTarget(name: name) + } + }() + + projectTargets.append(target) + } + return projectTargets + } +} + +extension TargetHandler { + static func makeAppTarget( + name: String, + dependencies: [TargetDependency] + ) -> Target { + return TargetHandler.makeTarget( + targetType: .app, + name: name, + bundleID: "\(env.bundlePrefix).\(name.contains("Demo") ? "test" : "release")", + infoPlist: InfoPlistProvider.forApp(name: name), + resources: [.glob(pattern: "Resources/**", excluding: [])], + entitlements: "\(name).entitlements", + dependencies: dependencies + ) + } + + static func makeInterfaceTarget( + name: String, + dependencies: [TargetDependency] + ) -> Target { + return TargetHandler.makeTarget( + targetType: .interface, + name: "\(name)Interface", + bundleID: "\(env.bundlePrefix).\(name)Interface", + dependencies: dependencies + ) + } + + static func makeDemoTarget(name: String) -> Target { + return TargetHandler.makeTarget( + targetType: .demo, + name: "\(name)Demo", + bundleID: "com.hmh.hamyeonham", //"\(env.bundlePrefix).\(name)Demo", + infoPlist: .extendingDefault(with: Project.demoInfoPlist), + resources: [.glob(pattern: "Demo/Resources/**", excluding: ["Demo/Resources/dummy.txt"])], + dependencies: [.target(name:name)] + ) + } + + static func makeUnitTestTarget(name: String) -> Target { + return TargetHandler.makeTarget( + targetType: .unitTest, + name: "\(name)Tests", + bundleID: "\(env.bundlePrefix).\(name)Tests", + dependencies: [.target(name: name)] + ) + } + + static func makeFrameworkTarget( + targetType: FeatureTarget, + name: String, + hasResources: Bool, + dependencies: [TargetDependency] + ) -> Target { + return TargetHandler.makeTarget( + targetType: targetType, + name: name, + bundleID: "\(env.bundlePrefix).\(name)", + resources: hasResources ? [.glob(pattern: "Resources/**", excluding: [])] : [], + dependencies: dependencies + ) + } +} diff --git a/HMH_Tuist_iOS/graph.png b/HMH_Tuist_iOS/graph.png index a24d1a00..814fb2a1 100644 Binary files a/HMH_Tuist_iOS/graph.png and b/HMH_Tuist_iOS/graph.png differ diff --git a/HMH_iOS/HMH_iOS/Global/Resource/String.swift b/HMH_iOS/HMH_iOS/Global/Resource/String.swift index c8cd6566..b4abe2df 100644 --- a/HMH_iOS/HMH_iOS/Global/Resource/String.swift +++ b/HMH_iOS/HMH_iOS/Global/Resource/String.swift @@ -38,7 +38,7 @@ enum StringLiteral { static let createButton = "챌린지 생성하기" static let pointTitle = "일차 보상" static let pointSubTitle = "일 챌린지" - static let pointButton = "+" + static let pointButton = "+20P" } enum MyPage { @@ -163,6 +163,7 @@ enum StringLiteral { enum MyPageURL { static var term = "https://msmmx.notion.site/33acb29be57245f394eb93ddb2e3b8cc" static var info = "https://msmmx.notion.site/7006ac1eb36545c38ea2bdfc7e34d2cb" + static var openChat = "https://open.kakao.com/o/s5Im8leg" } enum Prepare { diff --git a/HMH_iOS/HMH_iOS/Presentation/Challenge/ViewModels/ChallengeViewModel.swift b/HMH_iOS/HMH_iOS/Presentation/Challenge/ViewModels/ChallengeViewModel.swift index 050f1168..99afd41b 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Challenge/ViewModels/ChallengeViewModel.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Challenge/ViewModels/ChallengeViewModel.swift @@ -40,6 +40,7 @@ final class ChallengeViewModel: ObservableObject { init() { getChallengeInfo() + judgeRemainPoint() } func getChallengeType() { @@ -78,6 +79,14 @@ final class ChallengeViewModel: ObservableObject { } } + func judgeRemainPoint() { + if statuses.contains("UNEARNED") { + remainEarnPoint = 1 + } else { + remainEarnPoint = 0 + } + } + func addApp(appGoalTime: Int) { var applist: [Apps] = [] diff --git a/HMH_iOS/HMH_iOS/Presentation/Challenge/Views/PointView.swift b/HMH_iOS/HMH_iOS/Presentation/Challenge/Views/PointView.swift index 85f0d9ee..fa9f923c 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Challenge/Views/PointView.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Challenge/Views/PointView.swift @@ -70,7 +70,7 @@ struct EarnPointButton: View { Button(action: { viewModel.patchEarnPoint(day: day) }, label: { - Text(StringLiteral.Challenge.pointButton + " \(viewModel.earnPoint)P") + Text(StringLiteral.Challenge.pointButton) .font(.text4_semibold_16) .foregroundStyle(buttonTextColor) .frame(width: 73, height: 40) diff --git a/HMH_iOS/HMH_iOS/Presentation/Common/Custom/ContentView.swift b/HMH_iOS/HMH_iOS/Presentation/Common/Custom/ContentView.swift index 778c78be..875a9369 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Common/Custom/ContentView.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Common/Custom/ContentView.swift @@ -25,13 +25,13 @@ struct ContentView: View { SplashView(viewModel: loginViewModel) } else { switch userManager.appState { - case .home: + case .onboarding: OnboardingContentView() case .onboardingComplete: OnboardingCompleteView() case .servicePrepare: ServicePrepareView() - case .onboarding: + case .home: TabBarView(showGuideView: $showGuideView) .onAppear { appStateViewModel.onAppear() diff --git a/HMH_iOS/HMH_iOS/Presentation/Common/Navigation/NavigationBarView.swift b/HMH_iOS/HMH_iOS/Presentation/Common/Navigation/NavigationBarView.swift index d3e6b403..f0bdc3ea 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Common/Navigation/NavigationBarView.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Common/Navigation/NavigationBarView.swift @@ -61,7 +61,7 @@ extension NavigationBarView { .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) } else if showPointButton { NavigationLink(destination: PointView(viewModel: .init())) { - point != 0 ? Image(.remainEarnPoint) : Image(.navigationPoint) + point == 0 ? Image(.navigationPoint) : Image(.remainEarnPoint) } .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) } else { diff --git a/HMH_iOS/HMH_iOS/Presentation/MyPage/ViewModels/MyPageViewModel.swift b/HMH_iOS/HMH_iOS/Presentation/MyPage/ViewModels/MyPageViewModel.swift index 72e848ce..ab9fefb0 100644 --- a/HMH_iOS/HMH_iOS/Presentation/MyPage/ViewModels/MyPageViewModel.swift +++ b/HMH_iOS/HMH_iOS/Presentation/MyPage/ViewModels/MyPageViewModel.swift @@ -65,7 +65,8 @@ class MyPageViewModel: ObservableObject { guard let url = URL(string: StringLiteral.MyPageURL.info) else {return} UIApplication.shared.open(url) case .market: - navigateToPrepare = true + guard let url = URL(string: StringLiteral.MyPageURL.openChat) else {return} + UIApplication.shared.open(url) Amplitude.instance().logEvent("view_shop", withEventProperties: ["view_type": "mypage"] ) default: return diff --git a/HMH_iOS/HMH_iOS/Presentation/Onboarding/ViewModels/OnboardingViewModel.swift b/HMH_iOS/HMH_iOS/Presentation/Onboarding/ViewModels/OnboardingViewModel.swift index 1b3ac70e..068fb581 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Onboarding/ViewModels/OnboardingViewModel.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Onboarding/ViewModels/OnboardingViewModel.swift @@ -99,7 +99,6 @@ class OnboardingViewModel: ObservableObject { onboardingState = .appGoalTimeSelect } else { addOnboardingState() - offIsCompleted() } case .appGoalTimeSelect: self.appGoalTime = convertToTotalMilliseconds(hour: selectedAppHour, minute: selectedAppMinute) diff --git a/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/AppGoalTimeView.swift b/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/AppGoalTimeView.swift index bd83d8d6..c770b19b 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/AppGoalTimeView.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/AppGoalTimeView.swift @@ -27,7 +27,7 @@ struct AppGoalTimeView: View { .foregroundColor(.gray2) } } - .padding(.bottom, 150) + .padding(.bottom, 100) } } diff --git a/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/OnboardingContentView.swift b/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/OnboardingContentView.swift index 15928ae6..e3d92b01 100644 --- a/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/OnboardingContentView.swift +++ b/HMH_iOS/HMH_iOS/Presentation/Onboarding/Views/OnboardingContentView.swift @@ -137,12 +137,17 @@ extension OnboardingContentView { .font(.title3_semibold_22) .lineSpacing(1.5) .foregroundStyle(.whiteText) + .fixedSize(horizontal: false, vertical: true) Text(onboardingViewModel.getOnboardigSub()) .font(.detail1_regular_14) .lineSpacing(1.5) .foregroundStyle(.gray2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) } + private func SurveyContainerView() -> some View { VStack {