diff --git a/BeforeGoing.xcodeproj/project.pbxproj b/BeforeGoing.xcodeproj/project.pbxproj index ae56052b..1dc6131c 100644 --- a/BeforeGoing.xcodeproj/project.pbxproj +++ b/BeforeGoing.xcodeproj/project.pbxproj @@ -31,6 +31,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 1827AF552F307CD200D18376 /* Exceptions for "BeforeGoing" folder in "BeforeGoingTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Data/BeforeGoingModel.xcdatamodel, + ); + target = 186E06F02DDDF10600E32490 /* BeforeGoingTests */; + }; 186E07032DDDF10600E32490 /* Exceptions for "BeforeGoing" folder in "BeforeGoing" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -45,6 +52,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 186E07032DDDF10600E32490 /* Exceptions for "BeforeGoing" folder in "BeforeGoing" target */, + 1827AF552F307CD200D18376 /* Exceptions for "BeforeGoing" folder in "BeforeGoingTests" target */, ); path = BeforeGoing; sourceTree = ""; diff --git a/BeforeGoing/Core/BeforeGoingError.swift b/BeforeGoing/Core/BeforeGoingError.swift index 11a3670f..e0e79333 100644 --- a/BeforeGoing/Core/BeforeGoingError.swift +++ b/BeforeGoing/Core/BeforeGoingError.swift @@ -30,4 +30,13 @@ enum BeforeGoingError: Error, Equatable { case tooManyRequset case withdrawFailed case notFoundProvider + + + case userIDNotFound + case memberNotFound + case termsNotFound + case scenarioNotFound + case notificationNotFound + case invalidMissionType + case invalidDateFormat } diff --git a/BeforeGoing/Data/BeforeGoingModel.xcdatamodel/contents b/BeforeGoing/Data/BeforeGoingModel.xcdatamodel/contents new file mode 100644 index 00000000..026816e7 --- /dev/null +++ b/BeforeGoing/Data/BeforeGoingModel.xcdatamodel/contents @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BeforeGoing/Data/DataDependencyAssembler.swift b/BeforeGoing/Data/DataDependencyAssembler.swift index 268c114b..ed2a7dd4 100644 --- a/BeforeGoing/Data/DataDependencyAssembler.swift +++ b/BeforeGoing/Data/DataDependencyAssembler.swift @@ -35,15 +35,7 @@ struct DataDependencyAssembler: DependencyAssembler { DIContainer.shared.register(updateNicknameRequestMapper) DIContainer.shared.register(type: AuthInterface.self) { _ in - AuthRepository( - networkService: networkService, - tokenReissuer: tokenReissuer, - keyChainService: keyChainService, - userDefaultsService: userDefaultsService, - nonceRequestMapper: nonceRequestMapper, - loginRequestMapper: loginRequestMapper, - tokenValidator: tokenValidator - ) + AuthRepository(userDefaultsService: userDefaultsService) } DIContainer.shared.register(type: TermsInterface.self) { _ in TermsRepository( @@ -55,12 +47,13 @@ struct DataDependencyAssembler: DependencyAssembler { ) } DIContainer.shared.register(type: MemberInterface.self) { _ in - MemberRepository( - networkService: networkService, - keyChainService: keyChainService, - userDefaultsService: userDefaultsService, - updateNicknameRequestMapper: updateNicknameRequestMapper - ) +// MemberRepository( +// networkService: networkService, +// keyChainService: keyChainService, +// userDefaultsService: userDefaultsService, +// updateNicknameRequestMapper: updateNicknameRequestMapper +// ) + MemberStorage(userDefaultsService: userDefaultsService) } DIContainer.shared.register(type: ScenarioInterface.self) { _ in ScenarioRepository( diff --git a/BeforeGoing/Data/Model/Notification/NotificationsDTO.swift b/BeforeGoing/Data/Model/Notification/NotificationsDTO.swift index e5667bff..212abfb3 100644 --- a/BeforeGoing/Data/Model/Notification/NotificationsDTO.swift +++ b/BeforeGoing/Data/Model/Notification/NotificationsDTO.swift @@ -23,7 +23,7 @@ struct ScenarioNotificationDTO: Decodable { extension NotificationsDTO { func toEntity() -> NotificationsEntity { let scenarios = scenarios.map { $0.toEntity() } - return .init(scenarios: scenarios) + return .init(notifications: scenarios) } } diff --git a/BeforeGoing/Data/Network/Extensions/Notification.Name+.swift b/BeforeGoing/Data/Network/Extensions/Notification.Name+.swift index 3f4d2e7d..32f6c6ff 100644 --- a/BeforeGoing/Data/Network/Extensions/Notification.Name+.swift +++ b/BeforeGoing/Data/Network/Extensions/Notification.Name+.swift @@ -7,6 +7,6 @@ import Foundation -extension Notification.Name { - static let loginExpired = Notification.Name("loginExpired") +extension Foundation.Notification.Name { + static let loginExpired = Foundation.Notification.Name("loginExpired") } diff --git a/BeforeGoing/Data/Persistence/CoreData/CoreDataStack.swift b/BeforeGoing/Data/Persistence/CoreData/CoreDataStack.swift new file mode 100644 index 00000000..c0f51f00 --- /dev/null +++ b/BeforeGoing/Data/Persistence/CoreData/CoreDataStack.swift @@ -0,0 +1,51 @@ +// +// CoreDataStack.swift +// BeforeGoing +// +// Created by APPLE on 2/3/26. +// + +import CoreData + +final class CoreDataStack { + + static let shared = CoreDataStack() + private init() {} + + // 모델을 static으로 분리해서 외부에서 접근 가능하게 + static let managedObjectModel: NSManagedObjectModel = { + let modelName = "BeforeGoingModel" + let uniqueBundles: [Bundle] = Bundle.allBundles + .compactMap { bundle -> (Bundle, URL)? in + guard let url = bundle.url(forResource: modelName, withExtension: "mom") else { + return nil + } + return (bundle, url) + } + .reduce(into: [(Bundle, URL)]()) { result, pair in + if !result.contains(where: { $0.1.path == pair.1.path }) { + result.append(pair) + } + } + .map { $0.0 } + + guard let model = NSManagedObjectModel.mergedModel(from: uniqueBundles) else { + fatalError("NSManagedObjectModel을 로드할 수 없습니다.") + } + return model + }() + + // 기존 container도 동일한 모델 인스턴스 사용 + private let container: NSPersistentContainer = { + let container = NSPersistentContainer( + name: "BeforeGoingModel", + managedObjectModel: CoreDataStack.managedObjectModel + ) + container.loadPersistentStores { _, error in + if let error { fatalError("Failed to load store: \(error)") } + } + return container + }() + + var context: NSManagedObjectContext { container.viewContext } +} diff --git a/BeforeGoing/Data/Persistence/Service/UserDefaultsKey.swift b/BeforeGoing/Data/Persistence/Service/UserDefaultsKey.swift index 5d25d9a7..5d2692d4 100644 --- a/BeforeGoing/Data/Persistence/Service/UserDefaultsKey.swift +++ b/BeforeGoing/Data/Persistence/Service/UserDefaultsKey.swift @@ -6,6 +6,7 @@ // enum UserDefaultsKey: String, CaseIterable { + case userID case provider case lastProvider } diff --git a/BeforeGoing/Data/Persistence/Service/UserDefaultsService.swift b/BeforeGoing/Data/Persistence/Service/UserDefaultsService.swift index 8c997b46..53a16ae5 100644 --- a/BeforeGoing/Data/Persistence/Service/UserDefaultsService.swift +++ b/BeforeGoing/Data/Persistence/Service/UserDefaultsService.swift @@ -46,3 +46,28 @@ struct UserDefaultsService: UserDefaultsProtocol { return UserDefaults.standard.value(forKey: key.rawValue) == nil } } + +final class MockUserDefaultsService: UserDefaultsProtocol { + + private var storage: [UserDefaultsKey: Any] = [:] + + func save(_ value: Any, key: UserDefaultsKey) -> Bool { + storage[key] = value + return true + } + + func load(key: UserDefaultsKey) -> T? { + storage[key] as? T + } + + func delete(key: UserDefaultsKey) -> Bool { + guard let _ = storage.removeValue(forKey: key) else { + return false + } + return true + } + + func deleteAll() { + storage.removeAll() + } +} diff --git a/BeforeGoing/Data/Repository/API/AuthRepository.swift b/BeforeGoing/Data/Repository/API/AuthRepository.swift new file mode 100644 index 00000000..5f181a8a --- /dev/null +++ b/BeforeGoing/Data/Repository/API/AuthRepository.swift @@ -0,0 +1,175 @@ +// +// AuthRepository.swift +// BeforeGoing +// +// Created by APPLE on 7/22/25. +// + +import Foundation + +struct AuthRepository: AuthInterface { + + private let userDefaultsService: UserDefaultsService + + init(userDefaultsService: UserDefaultsService) { + self.userDefaultsService = userDefaultsService + } + + func login() -> Bool { + guard let _: String = userDefaultsService.load(key: .userID) else { + return false + } + return true + } + +// func requestNonce(provider: Provider) async throws -> NonceEntity { +// let nonceRequestDTO = nonceRequestMapper.map(provider.rawValue) +// let response = try await networkService.request( +// endPoint: AuthAPI.nonce(dto: nonceRequestDTO), +// responseType: NonceResponseDTO.self +// ) +// return response.toEntity() +// } +// +// func requestLogin(provider: Provider) async throws -> Bool { +// let nonceEntity = try await requestNonce(provider: provider) +// let idToken = try await requestIDToken(nonce: nonceEntity.nonce) +// +// return try await requestLogin(provider: provider, idToken: idToken) +// } +// +// private func requestLogin(provider: Provider, idToken: String) async throws -> Bool { +// let requestDTO = loginRequestMapper.map((provider.rawValue, idToken)) +// let response = try await networkService.request( +// endPoint: AuthAPI.login(dto: requestDTO), +// responseType: LoginResponseDTO.self +// ) +// saveKeyChain(response: response) +// saveProvider(provider) +// +// let isAgreedTerms = try await isAgreedTerms(accessToken: response.accessToken) +// let isCompletedJoin = !response.isNewMember && isAgreedTerms +// return isCompletedJoin +// } +// +// func requestLogin(provider: Provider, idToken: String, name: String?) async throws -> Bool { +// let isCompletedJoin = try await requestLogin(provider: provider, idToken: idToken) +// +// if let name, +// !name.isBlank, +// let accessToken = keyChainService.load(key: .accessToken) { +// try await networkService.request( +// endPoint: MemberAPI.updateNickname( +// accessToken: accessToken, +// dto: .init(nickname: name) +// ) +// ) +// } +// +// return isCompletedJoin +// } +// +// func autoLogin() async throws -> Bool { +// guard let accessToken = keyChainService.load(key: .accessToken) else { +// return false +// } +// +// guard isTokenExists, +// try await isAgreedTerms(accessToken: accessToken) else { +// return false +// } +// +// guard let accessTokenExpirationDate = keyChainService.load(key: .accessTokenExpirationDate), +// let refreshTokenExpirationDate = keyChainService.load(key: .refreshTokenExpirationDate) else { +// return false +// } +// +// if tokenValidator.isAccessTokenValid(expirationDate: accessTokenExpirationDate) { +// return true +// } +// +// if tokenValidator.isRefreshTokenValid(expirationDate: refreshTokenExpirationDate) { +// do { +// try await tokenReissuer.reissue() +// return true +// } catch { +// return false +// } +// } +// return false +// } +// +// func getLastLogin() -> Provider? { +// guard let lastLogin: LastLogin = userDefaultsService.load(key: .lastProvider) else { +// return nil +// } +// return Provider(rawValue: lastLogin.provider) +// } +// +// func logout() async throws { +// guard let accessToken = keyChainService.load(key: .accessToken) else { +// BeforeGoingLogger.error(BeforeGoingError.accessTokenMissing) +// return +// } +// try await networkService.request(endPoint: AuthAPI.logout(accessToken: accessToken)) +// +// deleteUserInformation() +// } +// +// private func requestIDToken(nonce: String?) async throws -> String { +// return try await networkService.requestKakaoIDToken(nonce: nonce) +// } +// +// private func saveKeyChain(response: LoginResponseDTO) { +// let responseData: [KeyChainKey: String] = [ +// .accessToken: response.accessToken, +// .refreshToken: response.refreshToken, +// .accessTokenExpirationDate: tokenValidator.calculateExpirationDate( +// expiresIn: response.accessTokenExpiresIn +// ), +// .refreshTokenExpirationDate: tokenValidator.calculateExpirationDate( +// expiresIn: response.refreshTokenExpiresIn +// ) +// ] +// +// for data in responseData { +// keyChainService.save(data.value, forKey: data.key) +// } +// } +// +// private func saveProvider(_ provider: Provider) { +// let lastLogin = LastLogin(provider: provider.rawValue, timestamp: Date()) +// +// let _ = userDefaultsService.save(provider.rawValue, key: .provider) +// let _ = userDefaultsService.save(lastLogin, key: .lastProvider) +// } +// +// private var isTokenExists: Bool { +// if let accessToken = keyChainService.load(key: .accessToken), +// let refreshToken = keyChainService.load(key: .refreshToken), +// !accessToken.isEmpty, +// !refreshToken.isEmpty { +// return true +// } +// return false +// } +// +// private func isAgreedTerms(accessToken: String) async throws -> Bool { +// do { +// let _ = try await networkService.request( +// endPoint: TermsAPI.getTerms(accessToken: accessToken), +// responseType: TermsResponseDTO.self +// ) +// return true +// } catch { +// return false +// } +// } +// +// private func deleteUserInformation() { +// for key in KeyChainKey.allCases { +// keyChainService.delete(key: key) +// } +// let _ = userDefaultsService.delete(key: .provider) +// } +} diff --git a/BeforeGoing/Data/Repository/MemberRepository.swift b/BeforeGoing/Data/Repository/API/MemberRepository.swift similarity index 100% rename from BeforeGoing/Data/Repository/MemberRepository.swift rename to BeforeGoing/Data/Repository/API/MemberRepository.swift diff --git a/BeforeGoing/Data/Repository/MissionRepository.swift b/BeforeGoing/Data/Repository/API/MissionRepository.swift similarity index 100% rename from BeforeGoing/Data/Repository/MissionRepository.swift rename to BeforeGoing/Data/Repository/API/MissionRepository.swift diff --git a/BeforeGoing/Data/Repository/ScenarioRepository.swift b/BeforeGoing/Data/Repository/API/ScenarioRepository.swift similarity index 100% rename from BeforeGoing/Data/Repository/ScenarioRepository.swift rename to BeforeGoing/Data/Repository/API/ScenarioRepository.swift diff --git a/BeforeGoing/Data/Repository/TermsRepository.swift b/BeforeGoing/Data/Repository/API/TermsRepository.swift similarity index 100% rename from BeforeGoing/Data/Repository/TermsRepository.swift rename to BeforeGoing/Data/Repository/API/TermsRepository.swift diff --git a/BeforeGoing/Data/Repository/AuthRepository.swift b/BeforeGoing/Data/Repository/AuthRepository.swift deleted file mode 100644 index 5a00cd37..00000000 --- a/BeforeGoing/Data/Repository/AuthRepository.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// AuthRepository.swift -// BeforeGoing -// -// Created by APPLE on 7/22/25. -// - -import Foundation - -struct AuthRepository: AuthInterface { - - private let networkService: NetworkService - private let tokenReissuer: TokenReissuer - private let keyChainService: KeyChainService - private let userDefaultsService: UserDefaultsService - private let nonceRequestMapper: NonceRequestMapper - private let loginRequestMapper: LoginRequestMapper - private let tokenValidator: TokenValidator - - init( - networkService: NetworkService, - tokenReissuer: TokenReissuer, - keyChainService: KeyChainService, - userDefaultsService: UserDefaultsService, - nonceRequestMapper: NonceRequestMapper, - loginRequestMapper: LoginRequestMapper, - tokenValidator: TokenValidator - ) { - self.networkService = networkService - self.tokenReissuer = tokenReissuer - self.keyChainService = keyChainService - self.userDefaultsService = userDefaultsService - self.nonceRequestMapper = nonceRequestMapper - self.loginRequestMapper = loginRequestMapper - self.tokenValidator = tokenValidator - } - - func requestNonce(provider: Provider) async throws -> NonceEntity { - let nonceRequestDTO = nonceRequestMapper.map(provider.rawValue) - let response = try await networkService.request( - endPoint: AuthAPI.nonce(dto: nonceRequestDTO), - responseType: NonceResponseDTO.self - ) - return response.toEntity() - } - - func requestLogin(provider: Provider) async throws -> Bool { - let nonceEntity = try await requestNonce(provider: provider) - let idToken = try await requestIDToken(nonce: nonceEntity.nonce) - - return try await requestLogin(provider: provider, idToken: idToken) - } - - private func requestLogin(provider: Provider, idToken: String) async throws -> Bool { - let requestDTO = loginRequestMapper.map((provider.rawValue, idToken)) - let response = try await networkService.request( - endPoint: AuthAPI.login(dto: requestDTO), - responseType: LoginResponseDTO.self - ) - saveKeyChain(response: response) - saveProvider(provider) - - let isAgreedTerms = try await isAgreedTerms(accessToken: response.accessToken) - let isCompletedJoin = !response.isNewMember && isAgreedTerms - return isCompletedJoin - } - - func requestLogin(provider: Provider, idToken: String, name: String?) async throws -> Bool { - let isCompletedJoin = try await requestLogin(provider: provider, idToken: idToken) - - if let name, - !name.isBlank, - let accessToken = keyChainService.load(key: .accessToken) { - try await networkService.request( - endPoint: MemberAPI.updateNickname( - accessToken: accessToken, - dto: .init(nickname: name) - ) - ) - } - - return isCompletedJoin - } - - func autoLogin() async throws -> Bool { - guard let accessToken = keyChainService.load(key: .accessToken) else { - return false - } - - guard isTokenExists, - try await isAgreedTerms(accessToken: accessToken) else { - return false - } - - guard let accessTokenExpirationDate = keyChainService.load(key: .accessTokenExpirationDate), - let refreshTokenExpirationDate = keyChainService.load(key: .refreshTokenExpirationDate) else { - return false - } - - if tokenValidator.isAccessTokenValid(expirationDate: accessTokenExpirationDate) { - return true - } - - if tokenValidator.isRefreshTokenValid(expirationDate: refreshTokenExpirationDate) { - do { - try await tokenReissuer.reissue() - return true - } catch { - return false - } - } - return false - } - - func getLastLogin() -> Provider? { - guard let lastLogin: LastLogin = userDefaultsService.load(key: .lastProvider) else { - return nil - } - return Provider(rawValue: lastLogin.provider) - } - - func logout() async throws { - guard let accessToken = keyChainService.load(key: .accessToken) else { - BeforeGoingLogger.error(BeforeGoingError.accessTokenMissing) - return - } - try await networkService.request(endPoint: AuthAPI.logout(accessToken: accessToken)) - - deleteUserInformation() - } - - private func requestIDToken(nonce: String?) async throws -> String { - return try await networkService.requestKakaoIDToken(nonce: nonce) - } - - private func saveKeyChain(response: LoginResponseDTO) { - let responseData: [KeyChainKey: String] = [ - .accessToken: response.accessToken, - .refreshToken: response.refreshToken, - .accessTokenExpirationDate: tokenValidator.calculateExpirationDate( - expiresIn: response.accessTokenExpiresIn - ), - .refreshTokenExpirationDate: tokenValidator.calculateExpirationDate( - expiresIn: response.refreshTokenExpiresIn - ) - ] - - for data in responseData { - keyChainService.save(data.value, forKey: data.key) - } - } - - private func saveProvider(_ provider: Provider) { - let lastLogin = LastLogin(provider: provider.rawValue, timestamp: Date()) - - let _ = userDefaultsService.save(provider.rawValue, key: .provider) - let _ = userDefaultsService.save(lastLogin, key: .lastProvider) - } - - private var isTokenExists: Bool { - if let accessToken = keyChainService.load(key: .accessToken), - let refreshToken = keyChainService.load(key: .refreshToken), - !accessToken.isEmpty, - !refreshToken.isEmpty { - return true - } - return false - } - - private func isAgreedTerms(accessToken: String) async throws -> Bool { - do { - let _ = try await networkService.request( - endPoint: TermsAPI.getTerms(accessToken: accessToken), - responseType: TermsResponseDTO.self - ) - return true - } catch { - return false - } - } - - private func deleteUserInformation() { - for key in KeyChainKey.allCases { - keyChainService.delete(key: key) - } - let _ = userDefaultsService.delete(key: .provider) - } -} diff --git a/BeforeGoing/Data/Repository/Storage/AutoCounter.swift b/BeforeGoing/Data/Repository/Storage/AutoCounter.swift new file mode 100644 index 00000000..dec882a5 --- /dev/null +++ b/BeforeGoing/Data/Repository/Storage/AutoCounter.swift @@ -0,0 +1,31 @@ +// +// AutoCounter.swift +// BeforeGoing +// +// Created by APPLE on 2/16/26. +// + +import CoreData + +enum AutoCounter { + + static func getNextID( + for entityType: T.Type, + in context: NSManagedObjectContext + ) -> Int64 { + let entityName = String(describing: entityType) + let request = NSFetchRequest(entityName: entityName) + request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: false)] + request.fetchLimit = 1 + + let lastID = getID(context: context, request: request) as? Int64 ?? 0 + return lastID + 1 + } + + private static func getID( + context: NSManagedObjectContext, + request: NSFetchRequest + ) -> Any? { + try? context.fetch(request).first?.value(forKey: "id") + } +} diff --git a/BeforeGoing/Data/Repository/Storage/MemberStorage.swift b/BeforeGoing/Data/Repository/Storage/MemberStorage.swift new file mode 100644 index 00000000..967e3017 --- /dev/null +++ b/BeforeGoing/Data/Repository/Storage/MemberStorage.swift @@ -0,0 +1,75 @@ +// +// MemberStorage.swift +// BeforeGoing +// +// Created by APPLE on 2/5/26. +// + +import CoreData +import Foundation + +final class MemberStorage: MemberInterface { + + private let userDefaultsService: UserDefaultsProtocol + private let context: NSManagedObjectContext + + init( + userDefaultsService: UserDefaultsProtocol, + context: NSManagedObjectContext + ) { + self.userDefaultsService = userDefaultsService + self.context = context + } + + func updateNickname(nickname: String) async throws { + try await context.perform { [weak self] in + guard let self, + let member = try getMember() + else { + throw BeforeGoingError.memberNotFound + } + + member.nickname = nickname + + try context.save() + } + } + + func withdrawMember() async throws { + try await context.perform { [weak self] in + guard let self, + let member = try getMember() else { + throw BeforeGoingError.memberNotFound + } + + context.delete(member) + + try self.context.save() + let _ = userDefaultsService.delete(key: .userID) + } + } + + func getMemberName() async throws -> MemberNameEntity { + try await context.perform { [weak self] in + guard let self, + let member = try getMember(), + let memberName = member.nickname else { + throw BeforeGoingError.memberNotFound + } + + return .init(memberName: memberName) + } + } + + private func getMember() throws -> Member? { + guard let userID: Int = userDefaultsService.load(key: .userID) else { + return nil + } + + let request = Member.fetchRequest() + request.predicate = NSPredicate(format: "id == %d", userID) + + let member = try context.fetch(request).first + return member + } +} diff --git a/BeforeGoing/Data/Repository/Storage/MissionStorage.swift b/BeforeGoing/Data/Repository/Storage/MissionStorage.swift new file mode 100644 index 00000000..aab1b799 --- /dev/null +++ b/BeforeGoing/Data/Repository/Storage/MissionStorage.swift @@ -0,0 +1,189 @@ +// +// MissionStorage.swift +// BeforeGoing +// +// Created by APPLE on 2/16/26. +// + +import CoreData + +final class MissionStorage: MissionInterface { + + private let userDefaultsService: UserDefaultsProtocol + private let context: NSManagedObjectContext + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + init( + userDefaultsService: UserDefaultsProtocol, + context: NSManagedObjectContext + ) { + self.userDefaultsService = userDefaultsService + self.context = context + } + + func fetchMissions( + scenarioID: Int, + date: String + ) async throws -> MissionsEntity { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let date = try convertToDate(from: date) + let missions = try fetchMissions(scenarioID: scenarioID, date: date) + + let basicMissions: [BasicMissionEntity] = missions + .filter { $0.missionType == "basic" } + .map { + BasicMissionEntity( + missionId: Int($0.id), + content: $0.content ?? "", + isChecked: $0.isChecked, + missionType: $0.missionType ?? "" + ) + } + + let todayMissions: [TodayMissionEntity] = missions + .filter { $0.missionType == "today" } + .map { + TodayMissionEntity( + missionId: Int($0.id), + content: $0.content ?? "", + isChecked: $0.isChecked, + missionType: $0.missionType ?? "" + ) + } + + return MissionsEntity( + scenarioId: scenarioID, + basicMissions: basicMissions, + todayMissions: todayMissions + ) + } + } + + func checkMission( + missionID: Int, + date: String, + isChecked: Bool + ) async throws { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let mission = try fetchMission(missionID: missionID) + mission.isChecked = isChecked + mission.updatedAt = Date() + + try context.save() + } + } + + func addTodayMission( + scenarioID: Int, + date: String, + content: String + ) async throws -> TodayMissionEntity { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let date = try convertToDate(from: date) + let scenario = try fetchScenario(scenarioID: scenarioID) + + let mission = Mission(context: context) + mission.id = AutoCounter.getNextID(for: Mission.self, in: context) + mission.content = content + mission.isChecked = false + mission.missionType = "today" + mission.missionOrder = 0 + mission.useDate = date + mission.createdAt = Date() + mission.updatedAt = Date() + mission.scenario = scenario + + try context.save() + + return TodayMissionEntity( + missionId: Int(mission.id), + content: content, + isChecked: false, + missionType: "today" + ) + } + } + + func deleteTodayMission(missionID: Int) async throws { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let mission = try fetchMission(missionID: missionID) + + guard mission.missionType == "today" else { + throw BeforeGoingError.invalidMissionType + } + + context.delete(mission) + try context.save() + } + } +} + +// MARK: private method - Fetch + +extension MissionStorage { + + private func fetchMission(missionID: Int) throws -> Mission { + let request = Mission.fetchRequest() + request.predicate = NSPredicate(format: "id == %d", missionID) + + guard let mission = try context.fetch(request).first else { + throw BeforeGoingError.missionNotFound + } + + return mission + } + + private func fetchMissions(scenarioID: Int, date: Date) throws -> [Mission] { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { + throw BeforeGoingError.unknownError + } + + let request = Mission.fetchRequest() + request.predicate = NSPredicate( + format: "scenario.id == %d AND useDate >= %@ AND useDate < %@", + scenarioID, + startOfDay as NSDate, + endOfDay as NSDate + ) + + return try context.fetch(request) + } + + private func fetchScenario(scenarioID: Int) throws -> Scenario { + let request = Scenario.fetchRequest() + request.predicate = NSPredicate(format: "id == %d", scenarioID) + + guard let scenario = try context.fetch(request).first else { + throw BeforeGoingError.scenarioNotFound + } + + return scenario + } +} + +// MARK: private method - Convert + +extension MissionStorage { + + private func convertToDate(from dateString: String) throws -> Date { + guard let date = dateFormatter.date(from: dateString) else { + throw BeforeGoingError.invalidDateFormat + } + return date + } +} diff --git a/BeforeGoing/Data/Repository/Storage/ScenarioStorage.swift b/BeforeGoing/Data/Repository/Storage/ScenarioStorage.swift new file mode 100644 index 00000000..fbfb6455 --- /dev/null +++ b/BeforeGoing/Data/Repository/Storage/ScenarioStorage.swift @@ -0,0 +1,582 @@ +// +// ScenarioStorage.swift +// BeforeGoing +// +// Created by APPLE on 2/12/26. +// + +import CoreData + +final class ScenarioStorage: ScenarioInterface { + + private let seperator: String = "," + private let orderStep = 100 + private let userDefaultService: UserDefaultsProtocol + private let context: NSManagedObjectContext + + init( + userDefaultService: UserDefaultsProtocol, + context: NSManagedObjectContext + ) { + self.userDefaultService = userDefaultService + self.context = context + } + + func addScenario( + scenarioName: String, + memo: String, + basicMissions: [String], + isNotificationActive: Bool, + noticeMethodType: String?, + daysOfWeekOrdinal: [Int]?, + startHour: Int?, + startMinute: Int? + ) async throws -> ScenarioEntity { + try await context.perform { [weak self] in + guard let self, + let userID: Int = userDefaultService.load(key: .userID) else { + throw BeforeGoingError.memberNotFound + } + + let member = try fetchMember(userID: userID) + let maxOrder = try fetchMaxScenarioOrder(member: member) + let scenario = createScenario( + userID: userID, + scenarioName: scenarioName, + memo: memo, + maxOrder: maxOrder, + member: member + ) + + basicMissions.forEach { + self.createMission(content: $0, scenario: scenario) + } + + let notification = self.createNotification( + isNotificationActive: isNotificationActive, + noticeMethodType: noticeMethodType, + daysOfWeekOrdinal: daysOfWeekOrdinal, + scenario: scenario + ) + + if let startHour, let startMinute { + createTimeNotification(startHour: startHour, startMinute: startMinute, notification: notification) + } + + try context.save() + + return .init( + scenarioId: Int(scenario.id), + scenarioName: scenarioName, + memo: memo, + scenarioOrder: Int(scenario.scenarioOrder) + ) + } + } + + func fetchScenario(scenarioID: Int) async throws -> ScenarioWithNotificationEntity { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let scenario = try fetchScenario(scenarioID: scenarioID) + let missions: [MissionEntity] = createMissions(scenario: scenario) + + guard let notification = scenario.notification else { + throw BeforeGoingError.notificationNotFound + } + + let timeNotification = (notification.timeNotifications as? Set)?.first + + let notificationCondition: NotificationConditionEntity? = timeNotification.map { + NotificationConditionEntity( + notificationType: notification.notificationType ?? "", + startHour: Int($0.startHour), + startMinute: Int($0.startMinute) + ) + } + + let anyNotification: AnyNotificationEntity = notification.isActive + ? createActiveNotification(notification: notification) + : createInactiveNotification(notification: notification) + + return .init( + scenarioID: Int(scenario.id), + scenarioName: scenario.scenarioName ?? "", + memo: scenario.memo ?? "", + basicMissions: missions, + notification: anyNotification, + notificationCondition: notificationCondition + ) + } + } + + func fetchScenarios() async throws -> [ScenarioEntity] { + try await context.perform { [weak self] in + guard let self, + let userID: Int = userDefaultService.load(key: .userID) + else { + throw BeforeGoingError.memberNotFound + } + + let member = try fetchMember(userID: userID) + let scenarios = try fetchScenarios(member: member) + + return scenarios.map { + .init( + scenarioId: Int($0.id), + scenarioName: $0.scenarioName ?? "", + memo: $0.memo ?? "", + scenarioOrder: Int($0.scenarioOrder) + ) + } + } + } + + func deleteScenario(scenarioID: Int) async throws { + try await context.perform { [weak self] in + guard let self else { return } + + let scenario = try fetchScenario(scenarioID: scenarioID) + + context.delete(scenario) + try context.save() + } + } + + func updateScenario( + scenarioID: Int, + scenarioName: String, + memo: String, + missions: [(missionID: Int?, content: String)], + isNotificationActive: Bool, + noticeMethodType: String?, + daysOfWeekOrdinal: [Int]?, + startHour: Int?, + startMinute: Int? + ) async throws -> ScenarioEntity { + try await context.perform { [weak self] in + guard let self else { throw BeforeGoingError.unknownError } + + let scenario = try fetchScenario(scenarioID: scenarioID) + + updateScenario( + scenario: scenario, + scenarioName: scenarioName, + memo: memo + ) + updateMissions( + scenario: scenario, + missions: missions + ) + let notification = try updateNotification( + scenario: scenario, + isNotificationActive: isNotificationActive, + noticeMethodType: noticeMethodType, + daysOfWeekOrdinal: daysOfWeekOrdinal + ) + updateTimeNotification( + notification: notification, + startHour: startHour, + startMinute: startMinute + ) + + try context.save() + + return ScenarioEntity( + scenarioId: Int(scenario.id), + scenarioName: scenarioName, + memo: memo, + scenarioOrder: Int(scenario.scenarioOrder) + ) + } + } + + func updateScenarioOrder( + scenarioID: Int, + prevOrder: Int?, + nextOrder: Int? + ) async throws -> NewScenarioOrderEntity { + try await context.perform { [weak self] in + guard let self, + let userID: Int = userDefaultService.load(key: .userID) else { + throw BeforeGoingError.memberNotFound + } + + let member = try fetchMember(userID: userID) + let targetScenario = try fetchScenario(scenarioID: scenarioID) + + let newOrder: Int = calculateNewOrder( + prevOrder: prevOrder, + nextOrder: nextOrder + ) + let needsReorder = newOrder == prevOrder || newOrder == nextOrder + + var orderUpdates: [NewOrderEntity] + + if needsReorder { + orderUpdates = try reorderScenarios( + member: member, + scenario: targetScenario, + nextOrder: nextOrder + ) + } else { + orderUpdates = appendNewOrder( + scenario: targetScenario, + newOrder: newOrder + ) + } + + try context.save() + + return .init( + isReorder: needsReorder, + orderUpdates: orderUpdates + ) + } + } + + func fetchNotifications() async throws -> NotificationsEntity { + try await context.perform { [weak self] in + guard let self, + let userID: Int = userDefaultService.load(key: .userID) else { + throw BeforeGoingError.memberNotFound + } + + let member = try fetchMember(userID: userID) + let scenarios = try fetchScenarios(member: member) + let notificationEntities: [NotificationEntity] = fetchNotificationEntities(scenarios: scenarios) + + return NotificationsEntity(notifications: notificationEntities) + } + } +} + +// MARK: private method - Create Model + +extension ScenarioStorage { + + private func createScenario( + userID: Int, + scenarioName: String, + memo: String, + maxOrder: Int, + member: Member + ) -> Scenario { + let scenario = Scenario(context: context) + scenario.id = AutoCounter.getNextID(for: Scenario.self, in: context) + scenario.scenarioName = scenarioName + scenario.memo = memo + scenario.scenarioOrder = Int64(maxOrder + orderStep) + scenario.createdAt = Date() + scenario.updatedAt = Date() + scenario.member = member + + return scenario + } + + private func createMission( + content: String, + scenario: Scenario, + id: Int? = nil + ) { + let mission = Mission(context: context) + mission.id = id.map(Int64.init) ?? AutoCounter.getNextID(for: Mission.self, in: context) + mission.content = content + mission.isChecked = false + mission.missionType = "basic" + mission.missionOrder = 0 + mission.useDate = Date() + mission.createdAt = Date() + mission.updatedAt = Date() + mission.scenario = scenario + } + + private func createNotification( + isNotificationActive: Bool, + noticeMethodType: String?, + daysOfWeekOrdinal: [Int]?, + scenario: Scenario + ) -> Notification { + let notification = Notification(context: context) + notification.isActive = isNotificationActive + notification.notificationMethodType = noticeMethodType ?? "" + notification.notificationType = "push" + notification.daysOfWeek = daysOfWeekOrdinal?.map { String($0) }.joined(separator: seperator) ?? "" + notification.createdAt = Date() + notification.updatedAt = Date() + notification.scenario = scenario + + return notification + } + + private func createTimeNotification( + startHour: Int, + startMinute: Int, + notification: Notification + ) { + let timeNotification = TimeNotification(context: context) + timeNotification.startHour = Int64(startHour) + timeNotification.startMinute = Int64(startMinute) + timeNotification.createdAt = Date() + timeNotification.updatedAt = Date() + timeNotification.notification = notification + } +} + +// MARK: private method - Create Entity + +extension ScenarioStorage { + + private func createActiveNotification(notification: Notification) -> AnyNotificationEntity { + let daysOfWeekOrdinal = notification.daysOfWeek? + .split(separator: seperator) + .compactMap { Int($0) } ?? [] + + return .init( + active: ActiveNotificationEntity( + notificationID: Int(notification.id), + notificationType: notification.notificationType ?? "", + notificationMethodType: NoticeMethodType( + rawValue: notification.notificationMethodType ?? "" + ) ?? .alarm, + daysOfWeekOrdinal: daysOfWeekOrdinal + ) + ) + } + + private func createInactiveNotification(notification: Notification) -> AnyNotificationEntity { + .init( + inactive: InactiveNotificationEntity( + notificationID: Int(notification.id), + notificationType: notification.notificationType ?? "" + ) + ) + } + + private func createMissions(scenario: Scenario) -> [MissionEntity] { + return (scenario.missions as? Set)?.map { + MissionEntity( + missionId: Int($0.id), + content: $0.content ?? "", + isChecked: $0.isChecked, + missionType: $0.missionType ?? "" + ) + } ?? [] + } +} + +// MARK: private method - Fetch + +extension ScenarioStorage { + + private func fetchMember(userID: Int) throws -> Member { + let memberRequest = Member.fetchRequest() + memberRequest.predicate = NSPredicate(format: "id == %d", userID) + guard let member = try context.fetch(memberRequest).first else { + throw BeforeGoingError.memberNotFound + } + + return member + } + + private func fetchScenario(scenarioID: Int) throws -> Scenario { + let request = Scenario.fetchRequest() + request.predicate = NSPredicate(format: "id == %d", scenarioID) + + guard let scenario = try context.fetch(request).first else { + throw BeforeGoingError.scenarioNotFound + } + + return scenario + } + + private func fetchScenarios(member: Member) throws -> [Scenario] { + let request = Scenario.fetchRequest() + request.predicate = NSPredicate(format: "member == %@", member) + request.sortDescriptors = [NSSortDescriptor(key: "scenarioOrder", ascending: true)] + + let scenarios = try context.fetch(request) + return scenarios + } + + private func fetchNotificationEntities(scenarios: [Scenario]) -> [NotificationEntity] { + scenarios.compactMap { scenario in + guard let notification = scenario.notification, + notification.isActive, + let notificationType = notification.notificationType, + let methodType = notification.notificationMethodType, + let timeNotification = (notification.timeNotifications as? Set)?.first + else { + return nil + } + + let daysOfWeekOrdinal = notification.daysOfWeek? + .split(separator: self.seperator) + .compactMap { Int($0) } ?? [] + + let condition = NotificationConditionEntity( + notificationType: notificationType, + startHour: Int(timeNotification.startHour), + startMinute: Int(timeNotification.startMinute) + ) + + return .init( + scenarioID: Int(scenario.id), + scenarioName: scenario.scenarioName ?? "", + memo: scenario.memo ?? "", + notificationID: Int(notification.id), + notificationType: notificationType, + notificationMethodType: methodType, + daysOfWeekOrdinal: daysOfWeekOrdinal, + notificationCondition: condition + ) + } + } + + private func fetchMaxScenarioOrder(member: Member) throws -> Int { + let scenarios = try fetchScenarios(member: member) + return scenarios.map { Int($0.scenarioOrder) }.max() ?? 0 + } +} + +// MARK: private method - Update + +extension ScenarioStorage { + + private func updateScenario( + scenario: Scenario, + scenarioName: String, + memo: String + ) { + scenario.scenarioName = scenarioName + scenario.memo = memo + scenario.updatedAt = Date() + } + + private func updateMissions( + scenario: Scenario, + missions: [(missionID: Int?, content: String)] + ) { + if let existingMissions = scenario.missions as? Set { + existingMissions.forEach { self.context.delete($0) } + } + + missions.forEach { + createMission( + content: $0.content, + scenario: scenario, + id: $0.missionID + ) + } + } + + private func updateNotification( + scenario: Scenario, + isNotificationActive: Bool, + noticeMethodType: String?, + daysOfWeekOrdinal: [Int]? + ) throws -> Notification { + guard let notification = scenario.notification else { + throw BeforeGoingError.notificationNotFound + } + + notification.isActive = isNotificationActive + notification.notificationMethodType = noticeMethodType ?? "" + notification.daysOfWeek = daysOfWeekOrdinal?.map { String($0) }.joined(separator: seperator) ?? "" + notification.updatedAt = Date() + + return notification + } + + private func updateTimeNotification( + notification: Notification, + startHour: Int?, + startMinute: Int? + ) { + if let existing = notification.timeNotifications as? Set { + existing.forEach { self.context.delete($0) } + } + + if let startHour, let startMinute { + let timeNotification = TimeNotification(context: context) + timeNotification.startHour = Int64(startHour) + timeNotification.startMinute = Int64(startMinute) + timeNotification.createdAt = Date() + timeNotification.updatedAt = Date() + timeNotification.notification = notification + } + } +} + + +// MARK: private method - Order + +extension ScenarioStorage { + + private func calculateNewOrder(prevOrder: Int?, nextOrder: Int?) -> Int { + switch (prevOrder, nextOrder) { + case (nil, let next?): + next - 1 + case (let prev?, nil): + prev + 1 + case (let prev?, let next?): + (prev + next) / 2 + case (nil, nil): + 0 + } + } + + private func reorderScenarios( + member: Member, + scenario: Scenario, + nextOrder: Int? + ) throws -> [NewOrderEntity] { + var allScenarios = try fetchScenarios(member: member) + let relocatedScenarios = relocate( + allScenarios: allScenarios, + newScenario: scenario, + nextOrder: nextOrder + ) + return assignOrder(to: relocatedScenarios) + } + + private func relocate( + allScenarios: [Scenario], + newScenario: Scenario, + nextOrder: Int? + ) -> [Scenario] { + var scenarios = allScenarios + + scenarios.removeAll { $0.id == newScenario.id } + let insertIndex = getInsertIndex(allScenarios: scenarios, nextOrder: nextOrder) + scenarios.insert(newScenario, at: insertIndex) + + return scenarios + } + + private func assignOrder(to allScenarios: [Scenario]) -> [NewOrderEntity] { + var orderUpdates: [NewOrderEntity] = [] + + for (index, scenario) in allScenarios.enumerated() { + scenario.scenarioOrder = Int64(index) + orderUpdates.append(NewOrderEntity(id: Int(scenario.id), newOrder: index)) + } + + return orderUpdates + } + + private func getInsertIndex( + allScenarios: [Scenario], + nextOrder: Int? + ) -> Int { + if let next = nextOrder { + return allScenarios.firstIndex { Int($0.scenarioOrder) >= next } ?? allScenarios.count + } + return allScenarios.count + } + + private func appendNewOrder(scenario: Scenario, newOrder: Int) -> [NewOrderEntity] { + scenario.scenarioOrder = Int64(newOrder) + return [NewOrderEntity(id: Int(scenario.id), newOrder: newOrder)] + } +} diff --git a/BeforeGoing/Data/Repository/Storage/TermsStorage.swift b/BeforeGoing/Data/Repository/Storage/TermsStorage.swift new file mode 100644 index 00000000..21b7f65c --- /dev/null +++ b/BeforeGoing/Data/Repository/Storage/TermsStorage.swift @@ -0,0 +1,101 @@ +// +// TermsStorage.swift +// BeforeGoing +// +// Created by APPLE on 2/2/26. +// + +import CoreData +import Foundation + +final class TermsStorage: TermsInterface { + + private let userDefaultsService: UserDefaultsProtocol + private let context: NSManagedObjectContext + + init( + userDefaultsService: UserDefaultsProtocol, + context: NSManagedObjectContext + ) { + self.userDefaultsService = userDefaultsService + self.context = context + } + + func getAgreementTerms() async throws -> TermsEntity? { + try await context.perform { + let member = try self.fetchMember() + + guard let terms = member.term else { + return nil + } + + return TermsEntity( + id: Int(terms.id), + memberId: Int(member.id), + termsOfServiceAgreed: terms.termsOfServiceAgreed, + privacyPolicyAgreed: terms.privacyPolicyAgreed, + isOver14: terms.isOverFourteen, + eventPushAgreed: terms.eventPushAgreed + ) + } + } + + func sendAgreementTerms( + termsOfServiceAgreed: Bool, + privacyPolicyAgreed: Bool, + isOver14: Bool, + eventPushAgreed: Bool + ) async throws { + try await context.perform { + let member = try self.fetchMember() + + let terms = member.term ?? Terms(context: self.context) + let now = Date.now + + terms.termsOfServiceAgreed = termsOfServiceAgreed + terms.privacyPolicyAgreed = privacyPolicyAgreed + terms.isOverFourteen = isOver14 + terms.eventPushAgreed = eventPushAgreed + terms.updatedAt = now + + if member.term == nil { + terms.id = AutoCounter.getNextID(for: Terms.self, in: self.context) + terms.createdAt = now + member.term = terms + } + + try self.context.save() + } + } + + func updateAgreementTerm(eventPushAgreed: Bool) async throws { + try await context.perform { + let member = try self.fetchMember() + + guard let terms = member.term else { + throw BeforeGoingError.termsNotFound + } + + terms.eventPushAgreed = eventPushAgreed + terms.updatedAt = .now + + try self.context.save() + } + } + + private func fetchMember() throws -> Member { + guard let userID: Int = userDefaultsService.load(key: .userID) else { + throw BeforeGoingError.userIDNotFound + } + + let request = Member.fetchRequest() + request.predicate = NSPredicate(format: "id == %d", userID) + request.fetchLimit = 1 + + guard let member = try context.fetch(request).first else { + throw BeforeGoingError.memberNotFound + } + + return member + } +} diff --git a/BeforeGoing/Domain/DomainDependencyAssembler.swift b/BeforeGoing/Domain/DomainDependencyAssembler.swift index a0a8c174..b0675c7f 100644 --- a/BeforeGoing/Domain/DomainDependencyAssembler.swift +++ b/BeforeGoing/Domain/DomainDependencyAssembler.swift @@ -16,38 +16,6 @@ final class DomainDependencyAssembler: DependencyAssembler { } func assemble() { - let isUITestWithMock = ProcessInfo.processInfo.environment["USE_MOCK"] == "true" - - if isUITestWithMock { - DIContainer.shared.register(type: AutoLoginType.self) { _ in MockAutoLoginUseCase() } - DIContainer.shared.register(type: LoginType.self) { _ in MockLoginUseCase() } - DIContainer.shared.register(type: LogoutType.self) { _ in MockLogoutUseCase() } - - DIContainer.shared.register(type: FetchAgreeTermsType.self) { _ in MockFetchAgreeTermsUseCase() } - DIContainer.shared.register(type: SendAgreeTermsType.self) { _ in MockSendAgreeTermsUseCase() } - DIContainer.shared.register(type: UpdatePushNoticeType.self) { _ in MockUpdatePushNoticeUseCase() } - - DIContainer.shared.register(type: UpdateNicknameType.self) { _ in MockUpdateNicknameUseCase() } - DIContainer.shared.register(type: GetMemberNameType.self) { _ in MockGetMemberNameUseCase() } - DIContainer.shared.register(type: MemberWithdrawType.self) { _ in MockMemberWithdrawUseCase() } - - DIContainer.shared.register(type: FetchWeatherType.self) { _ in MockFetchWeatherUseCase() } - - DIContainer.shared.register(type: AddScenarioType.self) { _ in MockAddScenarioUseCase() } - DIContainer.shared.register(type: FetchScenariosType.self) { _ in MockFetchScenariosUseCase() } - DIContainer.shared.register(type: DeleteScenarioType.self) { _ in MockDeleteScenarioUseCase() } - DIContainer.shared.register(type: UpdateScenarioType.self) { _ in MockUpdateScenarioUseCase() } - DIContainer.shared.register(type: UpdateScenarioOrderType.self) { _ in MockUpdateScenarioOrderUseCase() } - DIContainer.shared.register(type: FetchSingleScenarioType.self) { _ in MockFetchSingleScenarioUseCase() } - - DIContainer.shared.register(type: FetchMissionsType.self) { _ in MockFetchMissionsUseCase() } - DIContainer.shared.register(type: CheckMissionType.self) { _ in MockCheckMissionUseCase() } - DIContainer.shared.register(type: AddTodayMissionType.self) { _ in MockAddTodayMissionUseCase() } - DIContainer.shared.register(type: DeleteTodayMissionType.self) { _ in MockDeleteTodayMissionUseCase() } - - return - } - dataDependencyAssembler.assemble() guard let authrepository = DIContainer.shared.resolve(type: AuthInterface.self) else { @@ -75,18 +43,9 @@ final class DomainDependencyAssembler: DependencyAssembler { return } - DIContainer.shared.register(type: AutoLoginType.self) { _ in - AutoLoginUseCase(repository: authrepository) - } DIContainer.shared.register(type: LoginType.self) { _ in LoginUseCase(repository: authrepository) } - DIContainer.shared.register(type: GetLastLoginType.self) { _ in - GetLastLoginUseCase(repository: authrepository) - } - DIContainer.shared.register(type: LogoutType.self) { _ in - return LogoutUseCase(repository: authrepository) - } DIContainer.shared.register(type: FetchAgreeTermsType.self) { _ in return FetchAgreeTermsUseCase(repository: termsRepository) @@ -94,9 +53,6 @@ final class DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: SendAgreeTermsType.self) { _ in return SendAgreeTermsUseCase(repository: termsRepository) } - DIContainer.shared.register(type: IsAppleLoginType.self) { _ in - return IsAppleLoginedUseCase(repository: memberRepository) - } DIContainer.shared.register(type: UpdatePushNoticeType.self) { _ in return UpdatePushNoticeUseCase(repository: termsRepository) } diff --git a/BeforeGoing/Domain/Entity/NotificationsEntity.swift b/BeforeGoing/Domain/Entity/NotificationsEntity.swift index 6560ad13..1f247a32 100644 --- a/BeforeGoing/Domain/Entity/NotificationsEntity.swift +++ b/BeforeGoing/Domain/Entity/NotificationsEntity.swift @@ -6,7 +6,7 @@ // struct NotificationsEntity { - let scenarios: [NotificationEntity] + let notifications: [NotificationEntity] } struct NotificationEntity { @@ -22,7 +22,7 @@ struct NotificationEntity { extension NotificationsEntity { static func stub() -> Self { - .init(scenarios: [.stub()]) + .init(notifications: [.stub()]) } } diff --git a/BeforeGoing/Domain/Interface/AuthInterface.swift b/BeforeGoing/Domain/Interface/AuthInterface.swift index cf6f128c..ddde6f7c 100644 --- a/BeforeGoing/Domain/Interface/AuthInterface.swift +++ b/BeforeGoing/Domain/Interface/AuthInterface.swift @@ -6,11 +6,5 @@ // protocol AuthInterface { - - func requestNonce(provider: Provider) async throws -> NonceEntity - func requestLogin(provider: Provider) async throws -> Bool - func requestLogin(provider: Provider, idToken: String, name: String?) async throws -> Bool - func autoLogin() async throws -> Bool - func getLastLogin() -> Provider? - func logout() async throws + func login() -> Bool } diff --git a/BeforeGoing/Domain/Interface/MemberInterface.swift b/BeforeGoing/Domain/Interface/MemberInterface.swift index 188de120..cae7729a 100644 --- a/BeforeGoing/Domain/Interface/MemberInterface.swift +++ b/BeforeGoing/Domain/Interface/MemberInterface.swift @@ -6,9 +6,7 @@ // protocol MemberInterface { - - var isAppleLogined: Bool? { get } - + func updateNickname(nickname: String) async throws func withdrawMember() async throws func getMemberName() async throws -> MemberNameEntity diff --git a/BeforeGoing/Domain/UseCase/Auth/AutoLoginUseCase.swift b/BeforeGoing/Domain/UseCase/Auth/AutoLoginUseCase.swift deleted file mode 100644 index 77f5ba35..00000000 --- a/BeforeGoing/Domain/UseCase/Auth/AutoLoginUseCase.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AutoLoginUseCase.swift -// BeforeGoing -// -// Created by APPLE on 9/12/25. -// - -protocol AutoLoginType { - func execute() async throws -> Bool -} - -struct AutoLoginUseCase: AutoLoginType { - - private let repository: AuthInterface - - init(repository: AuthInterface) { - self.repository = repository - } - - func execute() async throws -> Bool { - return try await repository.autoLogin() - } -} - -struct MockAutoLoginUseCase: AutoLoginType { - func execute() -> Bool { - return true - } -} diff --git a/BeforeGoing/Domain/UseCase/Auth/GetLastLoginUseCase.swift b/BeforeGoing/Domain/UseCase/Auth/GetLastLoginUseCase.swift deleted file mode 100644 index 0254f3eb..00000000 --- a/BeforeGoing/Domain/UseCase/Auth/GetLastLoginUseCase.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// GetLastLoginUseCase.swift -// BeforeGoing -// -// Created by APPLE on 11/22/25. -// - -protocol GetLastLoginType { - func execute() -> Provider? -} - -struct GetLastLoginUseCase: GetLastLoginType { - - private let repository: AuthInterface - - init(repository: AuthInterface) { - self.repository = repository - } - - func execute() -> Provider? { - repository.getLastLogin() - } -} - -struct MockGetLastLoginUseCase: GetLastLoginType { - func execute() -> Provider? { - .kakao - } -} diff --git a/BeforeGoing/Domain/UseCase/Auth/LoginUseCase.swift b/BeforeGoing/Domain/UseCase/Auth/LoginUseCase.swift index a6d6dbf2..e3122db1 100644 --- a/BeforeGoing/Domain/UseCase/Auth/LoginUseCase.swift +++ b/BeforeGoing/Domain/UseCase/Auth/LoginUseCase.swift @@ -6,10 +6,7 @@ // protocol LoginType { - - func login(provider: Provider) async throws -> Bool - func login(provider: Provider, idToken: String, name: String?) async throws -> Bool - func requestNonce(provider: Provider) async throws -> String + func execute() -> Bool } struct LoginUseCase: LoginType { @@ -20,34 +17,13 @@ struct LoginUseCase: LoginType { self.repository = repository } - func login(provider: Provider) async throws -> Bool { - try await repository.requestLogin(provider: provider) - } - - func login(provider: Provider, idToken: String, name: String?) async throws -> Bool { - return try await repository.requestLogin( - provider: provider, - idToken: idToken, - name: name - ) - } - - func requestNonce(provider: Provider) async throws -> String { - let nonceEntity = try await repository.requestNonce(provider: provider) - return nonceEntity.nonce + func execute() -> Bool { + repository.login() } } struct MockLoginUseCase: LoginType { - func login(provider: Provider) -> Bool { + func execute() -> Bool { return true } - - func login(provider: Provider, idToken: String, name: String?) -> Bool { - return true - } - - func requestNonce(provider: Provider) -> String { - return "nonce" - } } diff --git a/BeforeGoing/Domain/UseCase/Auth/LogoutUseCase.swift b/BeforeGoing/Domain/UseCase/Auth/LogoutUseCase.swift deleted file mode 100644 index 99c83d70..00000000 --- a/BeforeGoing/Domain/UseCase/Auth/LogoutUseCase.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LogoutUseCase.swift -// BeforeGoing -// -// Created by APPLE on 9/13/25. -// - -protocol LogoutType { - func execute() async throws -} - -struct LogoutUseCase: LogoutType { - - private let repository: AuthInterface - - init(repository: AuthInterface) { - self.repository = repository - } - - func execute() async throws { - try await repository.logout() - } -} - -struct MockLogoutUseCase: LogoutType { - - func execute() {} -} diff --git a/BeforeGoing/Presentation/Enum/DaysOfWeek.swift b/BeforeGoing/Presentation/Enum/DaysOfWeek.swift index 01b99c55..d3889e27 100644 --- a/BeforeGoing/Presentation/Enum/DaysOfWeek.swift +++ b/BeforeGoing/Presentation/Enum/DaysOfWeek.swift @@ -26,4 +26,15 @@ enum DaysOfWeek: Int, CaseIterable { return "일" } } + + static func toRawvalues(daysString: String) -> [Int] { + let daysOfWeek = daysString + .split(separator: ",") + .compactMap { day in + DaysOfWeek.allCases + .first { $0.string == day }? + .rawValue + } + return daysOfWeek + } } diff --git a/BeforeGoing/Presentation/Feature/Approach/View/Login/LoginView.swift b/BeforeGoing/Presentation/Feature/Approach/View/Login/LoginView.swift index 18f1fc91..3e5c64b7 100644 --- a/BeforeGoing/Presentation/Feature/Approach/View/Login/LoginView.swift +++ b/BeforeGoing/Presentation/Feature/Approach/View/Login/LoginView.swift @@ -16,13 +16,7 @@ final class LoginView: BaseView { private let appIconImageView = LottieAnimationView(name: "splashMotion") private let subtitleLabel = UILabel() private(set) var mainTitleLabel = UILabel() - private(set) var kakaoLoginButton = UIButton() - private(set) var appleLoginButton = UIButton() - private(set) var lastLoginBadgeView = LastLoginBadgeView() - - private(set) var appIconTopConstraint: Constraint? - private(set) var kakaoLoginTopConstraint: Constraint? - private var lastLoginBadgeBottomConstraint: Constraint? + private(set) var startButton = UIButton() override func setStyle() { backgrounImageView.do { @@ -43,15 +37,9 @@ final class LoginView: BaseView { $0.makeStrokeTextAttributes() $0.textAlignment = .center } - kakaoLoginButton.do { - $0.setImage(.kakaoLogin, for: .normal) - $0.alpha = 0 - } - appleLoginButton.do { - $0.setImage(.appleLogin, for: .normal) - $0.alpha = 0 - } - lastLoginBadgeView.do { + startButton.do { + $0.setTitle("시작하기", for: .normal) + $0.titleLabel?.font = .custom(.bodyLGMedium) $0.alpha = 0 } } @@ -62,9 +50,7 @@ final class LoginView: BaseView { appIconImageView, subtitleLabel, mainTitleLabel, - kakaoLoginButton, - appleLoginButton, - lastLoginBadgeView + startButton ) } @@ -73,7 +59,7 @@ final class LoginView: BaseView { $0.edges.equalToSuperview() } appIconImageView.snp.makeConstraints { - self.appIconTopConstraint = $0.top.equalToSuperview().inset(296.adjustedH).constraint + $0.top.equalToSuperview().inset(296.adjustedH) $0.centerX.equalToSuperview() $0.width.equalTo(180.adjustedW) $0.height.equalTo(150.adjustedH) @@ -88,42 +74,11 @@ final class LoginView: BaseView { $0.centerX.equalToSuperview() $0.height.equalTo(28.adjustedH) } - kakaoLoginButton.snp.makeConstraints { - self.kakaoLoginTopConstraint = $0.top.equalTo(mainTitleLabel.snp.bottom).offset(85.adjustedH).constraint - $0.leading.trailing.equalToSuperview().inset(20.adjustedW) - $0.height.equalTo(54.adjustedH) - } - appleLoginButton.snp.makeConstraints { - $0.top.equalTo(kakaoLoginButton.snp.bottom).offset(12.adjustedH) + startButton.snp.makeConstraints { + $0.top.equalTo(mainTitleLabel.snp.bottom).offset(85.adjustedH) $0.leading.trailing.equalToSuperview().inset(20.adjustedW) $0.bottom.equalToSuperview().inset(120.adjustedH) $0.height.equalTo(54.adjustedH) } - lastLoginBadgeView.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.bottom - .equalTo(kakaoLoginButton.snp.top) - .offset(20.adjustedH) - } - } -} - -extension LoginView { - - func updateLastLoginBadgeConstraint(provider: Provider?) { - switch provider { - case .none: - lastLoginBadgeView.snp.removeConstraints() - lastLoginBadgeView.removeFromSuperview() - case .kakao: - break - case .apple: - lastLoginBadgeView.snp.remakeConstraints { - $0.centerX.equalToSuperview() - $0.bottom - .equalTo(appleLoginButton.snp.top) - .offset(20.adjustedH) - } - } } } diff --git a/BeforeGoing/Presentation/Feature/Approach/ViewController/LoginViewController.swift b/BeforeGoing/Presentation/Feature/Approach/ViewController/LoginViewController.swift index 9e3e61aa..0576e555 100644 --- a/BeforeGoing/Presentation/Feature/Approach/ViewController/LoginViewController.swift +++ b/BeforeGoing/Presentation/Feature/Approach/ViewController/LoginViewController.swift @@ -29,27 +29,9 @@ final class LoginViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - Task { - do { - if let output = try await viewModel.action(input: .viewDidLoad) as? LoginViewModel.AutoLoginOutput, - output.isSucceed { - moveByNotification() - } - } catch { - BeforeGoingLogger.error(BeforeGoingError.autoLoginFailed) - } - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - Task { - guard let output = try await viewModel.action(input: .viewWillAppear) as? LoginViewModel.LastLoginOutput else { - return - } - - rootView.updateLastLoginBadgeConstraint(provider: output.lastLoginProvider) + let loginOutput = viewModel.action(input: .viewDidLoad) + if loginOutput.isRegisteredMember { + moveByNotification() } } @@ -59,22 +41,14 @@ final class LoginViewController: BaseViewController { } override func setAction() { - rootView.do { - $0.kakaoLoginButton.addTarget(self, action: #selector(kakaoLoginButtonDidTap), for: .touchUpInside) - $0.appleLoginButton.addTarget(self, action: #selector(appleLoginButtonDidTap), for: .touchUpInside) - } + rootView.startButton.addTarget(self, action: #selector(startButtonDidTap), for: .touchUpInside) } private func setAnimation() { DispatchQueue.main.async { UIView.animate(withDuration: 0.5, delay: 1.5, options: [.curveEaseOut]) { self.rootView.do { - $0.appIconTopConstraint?.update(inset: 230.adjustedH) - $0.kakaoLoginTopConstraint?.update(offset: 151.adjustedH) - - $0.kakaoLoginButton.alpha = 1 - $0.appleLoginButton.alpha = 1 - $0.lastLoginBadgeView.alpha = 1 + $0.startButton.alpha = 1 $0.layoutIfNeeded() } } @@ -85,35 +59,8 @@ final class LoginViewController: BaseViewController { extension LoginViewController: NetworkRequestable, NetworkRequestErrorHandler { @objc - func kakaoLoginButtonDidTap() { - Task { - do { - guard let output = try await viewModel.action( - input: .kakaoLoginDidTap - ) as? LoginViewModel.SocialLoginOutput else { - return - } - output.isRegisteredMember ? moveByNotification() : moveTerms() - } catch (let error) { - self.handleError(error) - BeforeGoingLogger.error(BeforeGoingError.loginFailed) - } - } - } - - @objc - func appleLoginButtonDidTap() { - Task { - do { - let _ = try await viewModel.action(input: .appleLoginDidTap) - viewModel.onAppleLoginPerformed = { [weak self] isMemberRegistered in - isMemberRegistered ? self?.moveByNotification() : self?.moveTerms() - } - } catch (let error) { - self.handleError(error) - BeforeGoingLogger.error(BeforeGoingError.loginFailed) - } - } + func startButtonDidTap() { + moveTerms() } private func moveByNotification() { @@ -123,7 +70,7 @@ extension LoginViewController: NetworkRequestable, NetworkRequestErrorHandler { guard let notificationIdentifier = NotificationIdentifier.convertIdentifier(from: request.identifier) else { return } - + switch notificationIdentifier { case .pushNotice : replaceViewController( diff --git a/BeforeGoing/Presentation/Feature/Approach/ViewModel/LoginViewModel.swift b/BeforeGoing/Presentation/Feature/Approach/ViewModel/LoginViewModel.swift index efba1916..2267f1f2 100644 --- a/BeforeGoing/Presentation/Feature/Approach/ViewModel/LoginViewModel.swift +++ b/BeforeGoing/Presentation/Feature/Approach/ViewModel/LoginViewModel.swift @@ -5,124 +5,29 @@ // Created by APPLE on 9/10/25. // -protocol LoginOutput {} - -import AuthenticationServices - -final class LoginViewModel: NSObject, ViewModeling { - - private let personNameFormatter: PersonNameComponentsFormatter = { - let formatter = PersonNameComponentsFormatter() - formatter.style = .default - return formatter - }() +final class LoginViewModel: ViewModeling { - private let autoLoginUseCase: AutoLoginType private let loginUseCase: LoginType - private let getLastLoginUseCase: GetLastLoginType - var onAppleLoginPerformed: ((Bool) -> Void)? - - init( - autoLoginUseCase: AutoLoginType, - loginUseCase: LoginType, - getLastLoginUseCase: GetLastLoginType - ) { - self.autoLoginUseCase = autoLoginUseCase + init(loginUseCase: LoginType) { self.loginUseCase = loginUseCase - self.getLastLoginUseCase = getLastLoginUseCase } enum Input { - case viewWillAppear case viewDidLoad - case kakaoLoginDidTap - case appleLoginDidTap } typealias Output = LoginOutput - struct AutoLoginOutput: LoginOutput { - let isSucceed: Bool - } - - struct SocialLoginOutput: LoginOutput { + struct LoginOutput { let isRegisteredMember: Bool } - struct LastLoginOutput: LoginOutput { - let lastLoginProvider: Provider? - } - - struct EmptyOutput: LoginOutput {} - - func action(input: Input) async throws -> Output { + func action(input: Input) -> Output { switch input { - case .viewWillAppear: - let provider = getLastLoginUseCase.execute() - return LastLoginOutput(lastLoginProvider: provider) - case .viewDidLoad: - let isSucceedAutoLogin = try await autoLoginUseCase.execute() - return AutoLoginOutput(isSucceed: isSucceedAutoLogin) - - case .kakaoLoginDidTap: - let isRegisteredMember = try await loginUseCase.login(provider: .kakao) - return SocialLoginOutput(isRegisteredMember: isRegisteredMember) - - case .appleLoginDidTap: - let provider = ASAuthorizationAppleIDProvider() - let request = provider.createRequest() - request.requestedScopes = [.fullName] - - let nonce = try await loginUseCase.requestNonce(provider: .apple) - request.nonce = nonce - - let controller = ASAuthorizationController(authorizationRequests: [request]) - controller.do { - $0.delegate = self - $0.presentationContextProvider = self - $0.performRequests() - } - - return EmptyOutput() - } - } -} - -extension LoginViewModel: ASAuthorizationControllerDelegate { - - func authorizationController ( - controller: ASAuthorizationController, - didCompleteWithAuthorization authorization: ASAuthorization - ) { - guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let identityTokenData = credential.identityToken, - let idToken = String(data: identityTokenData, encoding: .utf8) else { - return - } - - let name = credential.fullName.flatMap { personNameFormatter.string(from: $0) } - - Task { - do { - let isMemberRegistered = try await loginUseCase.login( - provider: .apple, - idToken: idToken, - name: name - ) - onAppleLoginPerformed?(isMemberRegistered) - } catch (let error) { - BeforeGoingLogger.error(error) - BeforeGoingLogger.error(BeforeGoingError.loginFailed) - } + let isRegisteredMember = loginUseCase.execute() + return LoginOutput(isRegisteredMember: isRegisteredMember) } } } - -extension LoginViewModel: ASAuthorizationControllerPresentationContextProviding { - - func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - ViewControllerUtil.findTopWindow() - } -} diff --git a/BeforeGoing/Presentation/Feature/Scenario/ViewModel/GetScenariosViewModel.swift b/BeforeGoing/Presentation/Feature/Scenario/ViewModel/GetScenariosViewModel.swift index eaf73956..3f9a7bec 100644 --- a/BeforeGoing/Presentation/Feature/Scenario/ViewModel/GetScenariosViewModel.swift +++ b/BeforeGoing/Presentation/Feature/Scenario/ViewModel/GetScenariosViewModel.swift @@ -56,7 +56,7 @@ final class GetScenariosViewModel: ViewModeling { case .requestNotifications: do { let result = try await fetchNotificationsUseCase.execute() - createScenarios(notifications: result.scenarios) + createScenarios(notifications: result.notifications) return NotificationsOutput(notificationsResult: .success(result)) } catch (let error) { return NotificationsOutput(notificationsResult: .failure(error)) diff --git a/BeforeGoing/Presentation/Feature/Setting/ViewController/ProfileViewController.swift b/BeforeGoing/Presentation/Feature/Setting/ViewController/ProfileViewController.swift index 90deca78..d37024ab 100644 --- a/BeforeGoing/Presentation/Feature/Setting/ViewController/ProfileViewController.swift +++ b/BeforeGoing/Presentation/Feature/Setting/ViewController/ProfileViewController.swift @@ -125,27 +125,7 @@ extension ProfileViewController: NetworkRequestable, NetworkRequestErrorHandler } private func defineLogout() -> () -> Void { - return { [weak self] in - guard let self = self else { return } - Task { - do { - guard let result = try await self.viewModel.action( - input: .logoutButtonDidTap - ) as? ProfileViewModel.LogoutOutput else { - return - } - if result.isSucceedLogout { - let loginViewController = ViewControllerFactory.shared.makeLoginViewController() - let navigationController = UINavigationController(rootViewController: loginViewController) - ViewControllerUtil.replaceRootViewController(to: navigationController) - return - } - } catch { - self.handleError(error) - BeforeGoingLogger.error(BeforeGoingError.logoutFailed) - } - } - } + return {} } private func defineWithdrawal() -> () -> Void { diff --git a/BeforeGoing/Presentation/Feature/Setting/ViewModel/ProfileViewModel.swift b/BeforeGoing/Presentation/Feature/Setting/ViewModel/ProfileViewModel.swift index 4f68fd46..54953810 100644 --- a/BeforeGoing/Presentation/Feature/Setting/ViewModel/ProfileViewModel.swift +++ b/BeforeGoing/Presentation/Feature/Setting/ViewModel/ProfileViewModel.swift @@ -10,22 +10,18 @@ protocol ProfileOutput {} final class ProfileViewModel: ViewModeling { private let getMemberNameUseCase: GetMemberNameType - private let logoutUseCase: LogoutType private let withdrawUseCase: MemberWithdrawType init( getMemberNameUseCase: GetMemberNameType, - logoutUseCase: LogoutType, withdrawUseCase: MemberWithdrawType ) { self.getMemberNameUseCase = getMemberNameUseCase - self.logoutUseCase = logoutUseCase self.withdrawUseCase = withdrawUseCase } enum Input { case viewWillAppear - case logoutButtonDidTap case withdrawButtonDidTap } @@ -35,10 +31,6 @@ final class ProfileViewModel: ViewModeling { let name: String } - struct LogoutOutput: ProfileOutput { - let isSucceedLogout: Bool - } - struct WithdrawOutput: ProfileOutput { let isSucceedWithdraw: Bool } @@ -48,14 +40,7 @@ final class ProfileViewModel: ViewModeling { case .viewWillAppear: let name = try await getMemberNameUseCase.execute() return MemberNameOutput(name: name) - case .logoutButtonDidTap: - do { - try await logoutUseCase.execute() - return LogoutOutput(isSucceedLogout: true) - } catch(let error) { - BeforeGoingLogger.error(error) - return LogoutOutput(isSucceedLogout: false) - } + case .withdrawButtonDidTap: do { try await withdrawUseCase.execute() diff --git a/BeforeGoing/Presentation/PresentationDependencyAssembler.swift b/BeforeGoing/Presentation/PresentationDependencyAssembler.swift index f786e0d6..14bd192f 100644 --- a/BeforeGoing/Presentation/PresentationDependencyAssembler.swift +++ b/BeforeGoing/Presentation/PresentationDependencyAssembler.swift @@ -21,31 +21,11 @@ struct PresentationDependencyAssembler: DependencyAssembler { fatalError() } - guard let autoLoginUseCase = DIContainer.shared.resolve(type: AutoLoginType.self) else { - BeforeGoingLogger.error(BeforeGoingError.diContainerError) - fatalError() - } - - guard let getLastLoginUseCase = DIContainer.shared.resolve(type: GetLastLoginType.self) else { - BeforeGoingLogger.error(BeforeGoingError.diContainerError) - fatalError() - } - - guard let logoutUseCase = DIContainer.shared.resolve(type: LogoutType.self) else { - BeforeGoingLogger.error(BeforeGoingError.diContainerError) - fatalError() - } - guard let agreeTermsUseCase = DIContainer.shared.resolve(type: SendAgreeTermsType.self) else { BeforeGoingLogger.error(BeforeGoingError.diContainerError) fatalError() } - guard let isAppleLoginedUseCase = DIContainer.shared.resolve(type: IsAppleLoginType.self) else { - BeforeGoingLogger.error(BeforeGoingError.diContainerError) - fatalError() - } - guard let updatePushNoticeUseCase = DIContainer.shared.resolve(type: UpdatePushNoticeType.self) else { BeforeGoingLogger.error(BeforeGoingError.diContainerError) fatalError() @@ -132,17 +112,7 @@ struct PresentationDependencyAssembler: DependencyAssembler { } DIContainer.shared.register( - AgreeItemViewModel( - sendAgreeUseCase: agreeTermsUseCase, - isAppleLoginedUseCase: isAppleLoginedUseCase - ) - ) - DIContainer.shared.register( - LoginViewModel( - autoLoginUseCase: autoLoginUseCase, - loginUseCase: loginUseCase, - getLastLoginUseCase: getLastLoginUseCase - ) + LoginViewModel(loginUseCase: loginUseCase) ) DIContainer.shared.register( HomeViewModel( @@ -157,7 +127,6 @@ struct PresentationDependencyAssembler: DependencyAssembler { DIContainer.shared.register( ProfileViewModel( getMemberNameUseCase: getMemberNameUseCase, - logoutUseCase: logoutUseCase, withdrawUseCase: withdrawUseCase ) ) diff --git a/BeforeGoingTests/UseCase/ContextProvider.swift b/BeforeGoingTests/UseCase/ContextProvider.swift new file mode 100644 index 00000000..6aa1aa84 --- /dev/null +++ b/BeforeGoingTests/UseCase/ContextProvider.swift @@ -0,0 +1,34 @@ +// +// ContextProvider.swift +// BeforeGoing +// +// Created by APPLE on 2/5/26. +// + +import CoreData + +enum ContextProvider { + + static func makeMockContext() -> NSManagedObjectContext { + // 앱과 동일한 모델 인스턴스를 재사용 + let container = NSPersistentContainer( + name: "BeforeGoingModel", + managedObjectModel: CoreDataStack.managedObjectModel + ) + + let description = NSPersistentStoreDescription() + description.url = URL(fileURLWithPath: "/dev/null/\(UUID().uuidString)") + description.type = NSInMemoryStoreType + description.shouldAddStoreAsynchronously = false + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores { _, error in + if let error { fatalError("Failed to load store: \(error)") } + } + + let context = container.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + return context + } +} diff --git a/BeforeGoingTests/UseCase/MemberUseCaseTest.swift b/BeforeGoingTests/UseCase/MemberUseCaseTest.swift new file mode 100644 index 00000000..8f0e45de --- /dev/null +++ b/BeforeGoingTests/UseCase/MemberUseCaseTest.swift @@ -0,0 +1,83 @@ +// +// MemberUseCaseTest.swift +// BeforeGoing +// +// Created by APPLE on 2/5/26. +// + +import CoreData +import Testing +@testable import BeforeGoing + +struct MemberUseCaseTest { + + private let context: NSManagedObjectContext + private let userDefaultsService: MockUserDefaultsService + private let memberRepository: MemberInterface + private let getMemberNamseUseCase: GetMemberNameUseCase + private let updateNickNameUseCase: UpdateNicknameUseCase + private let memberWithdrawUseCase: MemberWithdrawUseCase + + init() { + self.context = ContextProvider.makeMockContext() + self.userDefaultsService = .init() + self.memberRepository = MemberStorage(userDefaultsService: userDefaultsService, context: context) + self.getMemberNamseUseCase = .init(repository: memberRepository) + self.updateNickNameUseCase = .init(repository: memberRepository) + self.memberWithdrawUseCase = .init(repository: memberRepository) + } + + @Test("유저 이름 조회", arguments: ["test"]) + func getMemberName(nickname: String) async throws { + // give + let _ = try await createMember(id: 1, nickname: nickname) + + // when + let memberName = try await getMemberNamseUseCase.execute() + + // then + #expect(memberName == nickname) + } + + @Test("유저 이름 업데이트", arguments: ["user"]) + func updateMemberName(nickname: String) async throws { + // give + let _ = try await createMember(id: 1, nickname: "test") + + // when + let _ = try await updateNickNameUseCase.execute(nickname: nickname) + + // then + let memberName = try await getMemberNamseUseCase.execute() + #expect(memberName == nickname) + } + + @Test("유저 탈퇴") + func withdrawMemberName() async throws { + // give + let _ = try await createMember(id: 1, nickname: "test") + + // when + try await memberWithdrawUseCase.execute() + + // then + let fetchRequest = Member.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %d", 1) + let member = try context.fetch(fetchRequest) + + #expect(member.isEmpty) + } + + private func createMember(id: Int, nickname: String) async throws { + let _ = userDefaultsService.save(id, key: .userID) + + try await context.perform { + let member = Member(context: self.context) + member.id = Int64(id) + member.nickname = nickname + member.createdAt = .now + member.updatedAt = .now + try self.context.save() + } + } +} diff --git a/BeforeGoingTests/UseCase/MissionUseCaseTest.swift b/BeforeGoingTests/UseCase/MissionUseCaseTest.swift new file mode 100644 index 00000000..60df842a --- /dev/null +++ b/BeforeGoingTests/UseCase/MissionUseCaseTest.swift @@ -0,0 +1,123 @@ +// +// MissionUseCaseTest.swift +// BeforeGoing +// +// Created by APPLE on 2/16/26. +// + +import CoreData +import Testing +@testable import BeforeGoing + +struct MissionUseCaseTest { + + private let context: NSManagedObjectContext + private let userDefaultsService: MockUserDefaultsService + private let missionRepository: MissionInterface + private let scenarioRepository: ScenarioInterface + private let addScenarioUseCase: AddScenarioUseCase + private let addTodayMissionUseCase: AddTodayMissionUseCase + private let checkMissionUseCase: CheckMissionUseCase + private let deleteTodayMissionUseCase: DeleteTodayMissionUseCase + private let fetchMissionUseCase: FetchMissionsUseCase + + init() { + self.context = ContextProvider.makeMockContext() + self.userDefaultsService = .init() + self.missionRepository = MissionStorage( + userDefaultsService: userDefaultsService, + context: context + ) + self.scenarioRepository = ScenarioStorage( + userDefaultService: userDefaultsService, + context: context + ) + self.addScenarioUseCase = .init(repository: scenarioRepository) + self.addTodayMissionUseCase = .init(repository: missionRepository) + self.checkMissionUseCase = .init(repository: missionRepository) + self.deleteTodayMissionUseCase = .init(repository: missionRepository) + self.fetchMissionUseCase = .init(repository: missionRepository) + } + + @Test("오늘의 미션 추가") + func addTodayMission() async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenario() + let todayMission = try await addTodayMission(scenarioID: scenario.scenarioId) + + #expect(!todayMission.isChecked) + #expect(todayMission.content == "content") + #expect(todayMission.missionType == "today") + } + + @Test("미션 체크", arguments: [true, false]) + func checkMission(isChecked: Bool) async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenario() + let todayMission = try await addTodayMission(scenarioID: scenario.scenarioId) + + try await checkMissionUseCase.execute( + missionID: todayMission.missionId, + date: "2026-02-16", + isChecked: isChecked + ) + let fetchedMissions = try await fetchMissionUseCase.execute( + scenarioID: scenario.scenarioId, + date: "2026-02-16" + ) + + let fetchedTodayMission = fetchedMissions.todayMissions.first { todayMission.missionId == $0.missionId } + #expect(fetchedTodayMission?.isChecked == isChecked) + } + + @Test("미션 삭제") + func deleteMission() async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenario() + let todayMission = try await addTodayMission(scenarioID: scenario.scenarioId) + + try await deleteTodayMissionUseCase.execute(missionID: todayMission.missionId) + + let fetchedMissions = try await fetchMissionUseCase.execute(scenarioID: scenario.scenarioId, date: "2026-02-16") + let fetchedTodayMission = fetchedMissions.todayMissions.first { todayMission.missionId == $0.missionId } + + #expect(fetchedTodayMission == nil) + } +} + +extension MissionUseCaseTest { + + private func createMember(id: Int, nickname: String) async throws { + let _ = userDefaultsService.save(id, key: .userID) + + try await context.perform { + let member = Member(context: self.context) + member.id = Int64(id) + member.nickname = nickname + member.createdAt = .now + member.updatedAt = .now + try self.context.save() + } + } + + private func addScenario() async throws -> ScenarioEntity { + return try await addScenarioUseCase.execute( + scenarioName: "scenarioName", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + } + + private func addTodayMission(scenarioID: Int) async throws -> TodayMissionEntity { + return try await addTodayMissionUseCase.execute( + scenarioID: scenarioID, + date: "2026-02-16", + content: "content" + ) + } +} diff --git a/BeforeGoingTests/UseCase/ScenarioUseCaseTest.swift b/BeforeGoingTests/UseCase/ScenarioUseCaseTest.swift new file mode 100644 index 00000000..82c187d5 --- /dev/null +++ b/BeforeGoingTests/UseCase/ScenarioUseCaseTest.swift @@ -0,0 +1,266 @@ +// +// ScenarioUseCaseTest.swift +// BeforeGoing +// +// Created by APPLE on 3/4/26. +// + +import CoreData +import Testing +@testable import BeforeGoing + +struct ScenarioUseCaseTest { + + private let context: NSManagedObjectContext + private let userDefaultsService: MockUserDefaultsService + private let repository: ScenarioInterface + private let addScenarioUseCase: AddScenarioUseCase + private let deleteScenarioUseCase: DeleteScenarioUseCase + private let fetchNotificationsUseCase: FetchNotificationsUseCase + private let fetchScenariosUseCase: FetchScenariosUseCase + private let fetchSingleScenarioUseCase: FetchSingleScenarioUseCase + private let updateScenarioOrderUseCase: UpdateScenarioOrderUseCase + private let updateScenarioUseCase: UpdateScenarioUseCase + + init() { + self.context = ContextProvider.makeMockContext() + self.userDefaultsService = .init() + self.repository = ScenarioStorage(userDefaultService: userDefaultsService, context: context) + self.addScenarioUseCase = .init(repository: repository) + self.deleteScenarioUseCase = .init(repository: repository) + self.fetchNotificationsUseCase = .init(repository: repository) + self.fetchScenariosUseCase = .init(repository: repository) + self.fetchSingleScenarioUseCase = .init(repository: repository) + self.updateScenarioOrderUseCase = .init(repository: repository) + self.updateScenarioUseCase = .init(repository: repository) + } + + @Test("시나리오 추가") + func addScenario_success() async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenarioUseCase + .execute( + scenarioName: "scenario", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + let fetchedScenario = try await fetchSingleScenarioUseCase.execute(scenarioID: scenario.scenarioId) + + #expect(scenario.scenarioId == fetchedScenario.scenarioID) + #expect(scenario.scenarioName == fetchedScenario.scenarioName) + #expect(scenario.memo == fetchedScenario.memo) + } + + @Test("시나리오 삭제") + func deleteScenario_success() async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenarioUseCase + .execute( + scenarioName: "scenario", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + let _ = try await deleteScenarioUseCase.execute(scenarioID: scenario.scenarioId) + + let fetchRequest = Scenario.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "id == %d", scenario.scenarioId) + let fetchedScenario = try context.fetch(fetchRequest) + + #expect(fetchedScenario.isEmpty) + } + + @Test("알림 조회") + func fetchNotifications() async throws { + let scenarioName = "scenario" + let memo = "memo" + let basicMissions = ["mission"] + let notificationMethodType = "push" + let daysOfWeekOrdinal = [0, 1, 2] + let startHour = 1 + let startMinute = 15 + + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenarioUseCase + .execute( + scenarioName: scenarioName, + memo: memo, + basicMissions: basicMissions, + isNotificationActive: true, + noticeMethodType: notificationMethodType, + daysOfWeekOrdinal: daysOfWeekOrdinal, + startHour: startHour, + startMinute: startMinute + ) + + let notificationsEntity = try await fetchNotificationsUseCase.execute() + let notification = notificationsEntity.notifications.first { scenario.scenarioId == $0.scenarioID } + + #expect(notification?.scenarioName == scenarioName) + #expect(notification?.memo == memo) + #expect(notification?.notificationMethodType == notificationMethodType) + #expect(notification?.daysOfWeekOrdinal == daysOfWeekOrdinal) + #expect(notification?.notificationCondition.startHour == startHour) + #expect(notification?.notificationCondition.startMinute == startMinute) + #expect(notificationsEntity.notifications.count == 1) + } + + @Test("비활성화된 알림은 조회되지 않음") + func isNotificationInactive_fetchNotifications_count__zero() async throws { + try await createMember(id: 1, nickname: "test") + let _ = try await addScenarioUseCase + .execute( + scenarioName: "scenario", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + let notificationsEntity = try await fetchNotificationsUseCase.execute() + #expect(notificationsEntity.notifications.isEmpty) + } + + @Test("시나리오 목록 조회") + func fetchScenarios() async throws { + try await createMember(id: 1, nickname: "test") + for number in [1, 2, 3] { + let _ = try await addScenarioUseCase + .execute( + scenarioName: "scenario\(number)", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + } + + let fetchedScenarios = try await fetchScenariosUseCase.execute() + #expect(fetchedScenarios.count == 3) + #expect(fetchedScenarios[0].scenarioName == "scenario1") + #expect(fetchedScenarios[1].scenarioName == "scenario2") + #expect(fetchedScenarios[2].scenarioName == "scenario3") + } + + @Test("시나리오 순서 수정 - 재정렬 필요 없음") + func updateScenarioOrder_noNeedReorder() async throws { + try await createMember(id: 1, nickname: "test") + let scenarios: [ScenarioEntity] = try await createScenarios() + + let newOrder = try await updateScenarioOrderUseCase.execute( + scenarioID: scenarios[2].scenarioId, + prevOrder: scenarios[0].scenarioOrder, + nextOrder: scenarios[1].scenarioOrder + ) + + #expect(!newOrder.isReorder) + #expect(newOrder.orderUpdates.count == 1) + #expect(newOrder.orderUpdates.first?.id == scenarios[2].scenarioId) + #expect(newOrder.orderUpdates.first?.newOrder == 150) + } + + @Test("시나리오 순서 수정 - 재정렬 필요") + func updateScenarioOrder_needReorder() async throws { + try await createMember(id: 1, nickname: "test") + let scenarios: [ScenarioEntity] = try await createScenarios() + + let newOrder = try await updateScenarioOrderUseCase.execute( + scenarioID: scenarios[0].scenarioId, + prevOrder: 100, + nextOrder: 101 + ) + + #expect(newOrder.isReorder) + #expect(newOrder.orderUpdates.count == 3) + } + + @Test("시나리오 수정") + func updateScenario() async throws { + try await createMember(id: 1, nickname: "test") + let scenario = try await addScenarioUseCase + .execute( + scenarioName: "scenario", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + let fetchedScenario = try await fetchSingleScenarioUseCase.execute(scenarioID: scenario.scenarioId) + + let updatedScenario = try await updateScenarioUseCase + .execute( + scenarioID: scenario.scenarioId, + scenarioName: "newScenario", + memo: "newMemo", + missions: fetchedScenario.basicMissions.map { ($0.missionId, $0.content) }, + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + #expect(updatedScenario.scenarioName == "newScenario") + #expect(updatedScenario.memo == "newMemo") + #expect(updatedScenario.scenarioId == scenario.scenarioId) + #expect(updatedScenario.scenarioOrder == scenario.scenarioOrder) + } +} + +extension ScenarioUseCaseTest { + + private func createMember(id: Int, nickname: String) async throws { + let _ = userDefaultsService.save(id, key: .userID) + + try await context.perform { + let member = Member(context: self.context) + member.id = Int64(id) + member.nickname = nickname + member.createdAt = .now + member.updatedAt = .now + try self.context.save() + } + } + + private func createScenarios() async throws -> [ScenarioEntity] { + var scenarios: [ScenarioEntity] = [] + for number in [1, 2, 3] { + let scenario = try await addScenarioUseCase + .execute( + scenarioName: "scenario\(number)", + memo: "memo", + basicMissions: ["mission"], + isNotificationActive: false, + noticeMethodType: nil, + daysOfWeekOrdinal: nil, + startHour: nil, + startMinute: nil + ) + + scenarios.append(scenario) + } + + return scenarios + } +} diff --git a/BeforeGoingTests/UseCase/TermsUseCaseTest.swift b/BeforeGoingTests/UseCase/TermsUseCaseTest.swift new file mode 100644 index 00000000..f9e743ba --- /dev/null +++ b/BeforeGoingTests/UseCase/TermsUseCaseTest.swift @@ -0,0 +1,74 @@ +// +// TermsRepositoryTest.swift +// BeforeGoing +// +// Created by APPLE on 2/3/26. +// + +import CoreData +import Testing +@testable import BeforeGoing + +struct TermsUseCaseTest { + + private let context: NSManagedObjectContext + private let userDefaultsService: MockUserDefaultsService + private let repository: TermsInterface + private let fetchAgreeUseCase: FetchAgreeTermsUseCase + private let sendAgreeTermsUseCase: SendAgreeTermsUseCase + private let updatePushNoticeUseCase: UpdatePushNoticeUseCase + + init() async throws { + self.context = ContextProvider.makeMockContext() + self.userDefaultsService = .init() + self.repository = TermsStorage(userDefaultsService: userDefaultsService, context: context) + self.fetchAgreeUseCase = .init(repository: repository) + self.sendAgreeTermsUseCase = .init(repository: repository) + self.updatePushNoticeUseCase = .init(repository: repository) + + try await createMember(id: 1, nickname: "tester") + } + + @Test("약관 동의 내역 저장", arguments: [true, false]) + func sendAgreeTerms(eventPushAgreed: Bool) async throws { + try await sendAgreeTermsUseCase.execute( + termsOfServiceAgreed: true, + privacyPolicyAgreed: true, + isOver14: true, + eventPushAgreed: eventPushAgreed + ) + + let result = try await fetchAgreeUseCase.execute() + + #expect(result?.eventPushAgreed == eventPushAgreed) + } + + @Test("약관 동의 내역 수정", arguments: [true, false]) + func fetchAgreeTerms(eventPushAgreed: Bool) async throws { + try await sendAgreeTermsUseCase.execute( + termsOfServiceAgreed: true, + privacyPolicyAgreed: true, + isOver14: true, + eventPushAgreed: eventPushAgreed + ) + + try await updatePushNoticeUseCase.execute(eventPushAgreed: !eventPushAgreed) + + let result = try await fetchAgreeUseCase.execute() + + #expect(result?.eventPushAgreed == !eventPushAgreed) + } + + private func createMember(id: Int, nickname: String) async throws { + let _ = userDefaultsService.save(id, key: .userID) + + try await context.perform { + let member = Member(context: self.context) + member.id = Int64(id) + member.nickname = nickname + member.createdAt = .now + member.updatedAt = .now + try self.context.save() + } + } +}