From 5c9cecbcd57d9b167e35ed7018b8097e19116c0c Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 02:55:23 +0900 Subject: [PATCH 01/14] refactor: introduce auth/user/device-token domain models and protocols --- Siksha/Data/Repository/Repository.swift | 4 +- .../Data/Repository/RepositoryProtocol.swift | 6 +-- Siksha/Domain/Models/AppVersion.swift | 10 +++++ Siksha/Domain/Models/AuthSession.swift | 43 +++++++++++++++++++ Siksha/Domain/Models/AuthState.swift | 11 +++++ Siksha/Domain/Models/LoginCredential.swift | 22 ++++++++++ Siksha/Domain/Models/LoginProvider.swift | 12 ++++++ Siksha/Domain/Models/User.swift | 18 ++++++++ .../AppVersionRepositoryProtocol.swift | 10 +++++ .../Repository/AuthRepositoryProtocol.swift | 15 +++++++ .../DeviceTokenRepositoryProtocol.swift | 16 +++++++ .../Repository/UserRepositoryProtocol.swift | 19 ++++++++ .../Settings/RenewalSettingsViewModel.swift | 4 +- Siksha/Presentation/UserManager.swift | 4 +- Siksha/System/DI/RepositoryProvider.swift | 4 +- .../System/MenuAlarmNotificationManager.swift | 4 +- 16 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 Siksha/Domain/Models/AppVersion.swift create mode 100644 Siksha/Domain/Models/AuthSession.swift create mode 100644 Siksha/Domain/Models/AuthState.swift create mode 100644 Siksha/Domain/Models/LoginCredential.swift create mode 100644 Siksha/Domain/Models/LoginProvider.swift create mode 100644 Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/AuthRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/UserRepositoryProtocol.swift diff --git a/Siksha/Data/Repository/Repository.swift b/Siksha/Data/Repository/Repository.swift index a0f8d10f..f96d2722 100644 --- a/Siksha/Data/Repository/Repository.swift +++ b/Siksha/Data/Repository/Repository.swift @@ -114,7 +114,7 @@ extension Repository: CommunityRepositoryProtocol { } -extension Repository: UserRepositoryProtocol { +extension Repository: LegacyUserRepositoryProtocol { func loadUserInfo() -> AnyPublisher { let endpoint = SikshaAPI.getUserInfo return self.networkModule.request(endpoint: endpoint) @@ -134,7 +134,7 @@ extension Repository: UserRepositoryProtocol { } } -extension Repository: AuthRepositoryProtocol{ +extension Repository: LegacyAuthRepositoryProtocol { func postUserDevice(fcmToken: String)-> AnyPublisher{ let endpoint = SikshaAPI.postUserDevice(fcmToken: fcmToken) return self.networkModule.requestWithNoContent(endpoint: endpoint) diff --git a/Siksha/Data/Repository/RepositoryProtocol.swift b/Siksha/Data/Repository/RepositoryProtocol.swift index c9b5c3f9..9047bd25 100644 --- a/Siksha/Data/Repository/RepositoryProtocol.swift +++ b/Siksha/Data/Repository/RepositoryProtocol.swift @@ -8,7 +8,7 @@ import Foundation import Combine -protocol RepositoryProtocol: CommunityRepositoryProtocol, UserRepositoryProtocol, AuthRepositoryProtocol { +protocol RepositoryProtocol: CommunityRepositoryProtocol, LegacyUserRepositoryProtocol, LegacyAuthRepositoryProtocol { } @@ -34,14 +34,14 @@ protocol CommunityRepositoryProtocol { } -protocol UserRepositoryProtocol { +protocol LegacyUserRepositoryProtocol { func loadUserInfo() -> AnyPublisher func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher func submitVOC(comment: String, platform: String) -> AnyPublisher func deleteUser() -> AnyPublisher } -protocol AuthRepositoryProtocol{ +protocol LegacyAuthRepositoryProtocol { func postUserDevice(fcmToken: String)-> AnyPublisher func deleteUserDevice(fcmToken: String)-> AnyPublisher } diff --git a/Siksha/Domain/Models/AppVersion.swift b/Siksha/Domain/Models/AppVersion.swift new file mode 100644 index 00000000..273000f1 --- /dev/null +++ b/Siksha/Domain/Models/AppVersion.swift @@ -0,0 +1,10 @@ +// +// AppVersion.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +struct AppVersion: Equatable { + let rawValue: String +} diff --git a/Siksha/Domain/Models/AuthSession.swift b/Siksha/Domain/Models/AuthSession.swift new file mode 100644 index 00000000..0ffeea25 --- /dev/null +++ b/Siksha/Domain/Models/AuthSession.swift @@ -0,0 +1,43 @@ +// +// AuthSession.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AuthSession { + let accessToken: String + let expiresAt: Date? + let appleUserIdentifier: String? + + init( + accessToken: String, + expiresAt: Date?, + appleUserIdentifier: String? = nil + ) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.appleUserIdentifier = appleUserIdentifier + } + + func isExpired(asOf date: Date = Date()) -> Bool { + guard let expiresAt else { + return false + } + + return expiresAt <= date + } + + func needsRefresh( + asOf date: Date = Date(), + refreshWindow: TimeInterval = 15_552_000 + ) -> Bool { + guard let expiresAt, !isExpired(asOf: date) else { + return false + } + + return DateInterval(start: date, end: expiresAt).duration < refreshWindow + } +} diff --git a/Siksha/Domain/Models/AuthState.swift b/Siksha/Domain/Models/AuthState.swift new file mode 100644 index 00000000..176a2555 --- /dev/null +++ b/Siksha/Domain/Models/AuthState.swift @@ -0,0 +1,11 @@ +// +// AuthState.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +enum AuthState { + case authenticated(AuthSession) + case requiresLogin +} diff --git a/Siksha/Domain/Models/LoginCredential.swift b/Siksha/Domain/Models/LoginCredential.swift new file mode 100644 index 00000000..92d1f63d --- /dev/null +++ b/Siksha/Domain/Models/LoginCredential.swift @@ -0,0 +1,22 @@ +// +// LoginCredential.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +struct LoginCredential { + let provider: LoginProvider + let token: String + let appleUserIdentifier: String? + + init( + provider: LoginProvider, + token: String, + appleUserIdentifier: String? = nil + ) { + self.provider = provider + self.token = token + self.appleUserIdentifier = appleUserIdentifier + } +} diff --git a/Siksha/Domain/Models/LoginProvider.swift b/Siksha/Domain/Models/LoginProvider.swift new file mode 100644 index 00000000..4e1c0540 --- /dev/null +++ b/Siksha/Domain/Models/LoginProvider.swift @@ -0,0 +1,12 @@ +// +// LoginProvider.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +enum LoginProvider: String { + case kakao + case google + case apple +} diff --git a/Siksha/Domain/Models/User.swift b/Siksha/Domain/Models/User.swift index 6680b873..154b2990 100644 --- a/Siksha/Domain/Models/User.swift +++ b/Siksha/Domain/Models/User.swift @@ -25,6 +25,24 @@ struct User: Decodable { let profileUrl: String? let createdAt: Date let updatedAt: Date + + init( + id: Int, + type: String, + identity: String, + nickname: String?, + profileUrl: String?, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.type = type + self.identity = identity + self.nickname = nickname + self.profileUrl = profileUrl + self.createdAt = createdAt + self.updatedAt = updatedAt + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift b/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift new file mode 100644 index 00000000..2d75eb5b --- /dev/null +++ b/Siksha/Domain/Repository/AppVersionRepositoryProtocol.swift @@ -0,0 +1,10 @@ +// +// AppVersionRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol AppVersionRepositoryProtocol { + func fetchLatestAppStoreVersion() async throws -> AppVersion +} diff --git a/Siksha/Domain/Repository/AuthRepositoryProtocol.swift b/Siksha/Domain/Repository/AuthRepositoryProtocol.swift new file mode 100644 index 00000000..ccc93898 --- /dev/null +++ b/Siksha/Domain/Repository/AuthRepositoryProtocol.swift @@ -0,0 +1,15 @@ +// +// AuthRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol AuthRepositoryProtocol { + func login(with credential: LoginCredential) async throws -> AuthSession + func loginForTest() async throws -> AuthSession + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession + func loadSession() -> AuthSession? + func saveSession(_ session: AuthSession) + func clearSession() +} diff --git a/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift b/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift new file mode 100644 index 00000000..4854cd13 --- /dev/null +++ b/Siksha/Domain/Repository/DeviceTokenRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// DeviceTokenRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol DeviceTokenRepositoryProtocol { + func registerDevice(fcmToken: String) async throws + func unregisterDevice(fcmToken: String) async throws + func loadDeviceToken() -> String? + func saveDeviceToken(_ token: String) + func isDeviceTokenRegistered() -> Bool + func setDeviceTokenRegistered(_ isRegistered: Bool) + func clearDeviceToken() +} diff --git a/Siksha/Domain/Repository/UserRepositoryProtocol.swift b/Siksha/Domain/Repository/UserRepositoryProtocol.swift new file mode 100644 index 00000000..3085615a --- /dev/null +++ b/Siksha/Domain/Repository/UserRepositoryProtocol.swift @@ -0,0 +1,19 @@ +// +// UserRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol UserRepositoryProtocol { + func fetchCurrentUser() async throws -> User + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User + func submitVOC(comment: String, platform: String) async throws + func deleteAccount() async throws +} diff --git a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift index 390f135a..c840ce60 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift @@ -13,8 +13,8 @@ import FirebaseMessaging class RenewalSettingsViewModel: ObservableObject { private var cancellables = Set() - private let repository: UserRepositoryProtocol = AppContainer.shared.domain.userRepository - private let authRepository:AuthRepositoryProtocol = AppContainer.shared.domain.authRepository + private let repository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository + private let authRepository: LegacyAuthRepositoryProtocol = AppContainer.shared.domain.authRepository private let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase @Published var error: AppError? diff --git a/Siksha/Presentation/UserManager.swift b/Siksha/Presentation/UserManager.swift index 4bca6c14..c9bcda77 100644 --- a/Siksha/Presentation/UserManager.swift +++ b/Siksha/Presentation/UserManager.swift @@ -29,9 +29,9 @@ class UserManager: ObservableObject { @Published var imageData: Data? private var cancellables = Set() - private let userRepository: UserRepositoryProtocol + private let userRepository: LegacyUserRepositoryProtocol - private init(userRepository: UserRepositoryProtocol = AppContainer.shared.domain.userRepository) { + private init(userRepository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository) { self.userRepository = userRepository } diff --git a/Siksha/System/DI/RepositoryProvider.swift b/Siksha/System/DI/RepositoryProvider.swift index 19604834..105d7046 100644 --- a/Siksha/System/DI/RepositoryProvider.swift +++ b/Siksha/System/DI/RepositoryProvider.swift @@ -18,11 +18,11 @@ final class RepositoryProvider { return self.repository } - var userRepository: UserRepositoryProtocol { + var userRepository: LegacyUserRepositoryProtocol { return self.repository } - var authRepository: AuthRepositoryProtocol{ + var authRepository: LegacyAuthRepositoryProtocol { return self.repository } } diff --git a/Siksha/System/MenuAlarmNotificationManager.swift b/Siksha/System/MenuAlarmNotificationManager.swift index db856cdc..7a614881 100644 --- a/Siksha/System/MenuAlarmNotificationManager.swift +++ b/Siksha/System/MenuAlarmNotificationManager.swift @@ -11,11 +11,11 @@ import FirebaseMessaging import Combine final class DefaultMenuAlarmNotificationManager: MenuAlarmNotificationManaging { - private let authRepository: AuthRepositoryProtocol + private let authRepository: LegacyAuthRepositoryProtocol private var cancellables = Set() private var isSendingFCMToken = false - init(authRepository: AuthRepositoryProtocol) { + init(authRepository: LegacyAuthRepositoryProtocol) { self.authRepository = authRepository } From d6feef1ac117e10258bb2f79aa07e298c99cbbd1 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 04:34:55 +0900 Subject: [PATCH 02/14] refactor: add auth/user data sources and repository implementations --- Siksha/Data/DTO/AppVersionDTO.swift | 16 ++++ Siksha/Data/DTO/AuthDTO.swift | 16 ++++ Siksha/Data/DTO/UserDTO.swift | 18 +++++ .../Local/AuthSessionLocalDataSource.swift | 79 +++++++++++++++++++ .../Network/AppVersionRemoteDataSource.swift | 42 ++++++++++ .../Data/Network/AuthRemoteDataSource.swift | 44 +++++++++++ Siksha/Data/Network/JWTTokenDecoder.swift | 46 +++++++++++ .../Data/Network/UserRemoteDataSource.swift | 63 +++++++++++++++ .../Repository/AppVersionRepositoryImpl.swift | 25 ++++++ .../Data/Repository/AuthRepositoryImpl.swift | 72 +++++++++++++++++ .../Data/Repository/UserRepositoryImpl.swift | 56 +++++++++++++ 11 files changed, 477 insertions(+) create mode 100644 Siksha/Data/DTO/AppVersionDTO.swift create mode 100644 Siksha/Data/DTO/AuthDTO.swift create mode 100644 Siksha/Data/DTO/UserDTO.swift create mode 100644 Siksha/Data/Local/AuthSessionLocalDataSource.swift create mode 100644 Siksha/Data/Network/AppVersionRemoteDataSource.swift create mode 100644 Siksha/Data/Network/AuthRemoteDataSource.swift create mode 100644 Siksha/Data/Network/JWTTokenDecoder.swift create mode 100644 Siksha/Data/Network/UserRemoteDataSource.swift create mode 100644 Siksha/Data/Repository/AppVersionRepositoryImpl.swift create mode 100644 Siksha/Data/Repository/AuthRepositoryImpl.swift create mode 100644 Siksha/Data/Repository/UserRepositoryImpl.swift diff --git a/Siksha/Data/DTO/AppVersionDTO.swift b/Siksha/Data/DTO/AppVersionDTO.swift new file mode 100644 index 00000000..0de98551 --- /dev/null +++ b/Siksha/Data/DTO/AppVersionDTO.swift @@ -0,0 +1,16 @@ +// +// AppVersionDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AppVersionLookupResponseDTO: Decodable { + let results: [AppVersionResultDTO] +} + +struct AppVersionResultDTO: Decodable { + let version: String +} diff --git a/Siksha/Data/DTO/AuthDTO.swift b/Siksha/Data/DTO/AuthDTO.swift new file mode 100644 index 00000000..0c19b0f6 --- /dev/null +++ b/Siksha/Data/DTO/AuthDTO.swift @@ -0,0 +1,16 @@ +// +// AuthDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct AuthTokenResponseDTO: Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } +} diff --git a/Siksha/Data/DTO/UserDTO.swift b/Siksha/Data/DTO/UserDTO.swift new file mode 100644 index 00000000..7c5dfb6b --- /dev/null +++ b/Siksha/Data/DTO/UserDTO.swift @@ -0,0 +1,18 @@ +// +// UserDTO.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct UserDTO: Decodable { + let id: Int + let type: String + let identity: String + let nickname: String? + let profileUrl: String? + let createdAt: Date + let updatedAt: Date +} diff --git a/Siksha/Data/Local/AuthSessionLocalDataSource.swift b/Siksha/Data/Local/AuthSessionLocalDataSource.swift new file mode 100644 index 00000000..11382016 --- /dev/null +++ b/Siksha/Data/Local/AuthSessionLocalDataSource.swift @@ -0,0 +1,79 @@ +// +// AuthSessionLocalDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol AuthSessionLocalDataSource { + func loadSession() -> AuthSession? + func saveSession(_ session: AuthSession) + func clearSession() +} + +final class AuthSessionLocalDataSourceImpl: AuthSessionLocalDataSource { + private enum Key { + static let accessToken = "accessToken" + static let tokenExpDate = "tokenExpDate" + static let appleUserIdentifier = "appleUserIdentifier" + static let signedInWithApple = "signedInWithApple" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func loadSession() -> AuthSession? { + guard let accessToken = userDefaults.string(forKey: Key.accessToken) else { + return nil + } + + let expiresAt = expirationDate() + let appleUserIdentifier = userDefaults.bool(forKey: Key.signedInWithApple) + ? userDefaults.string(forKey: Key.appleUserIdentifier) + : nil + + return AuthSession( + accessToken: accessToken, + expiresAt: expiresAt, + appleUserIdentifier: appleUserIdentifier + ) + } + + func saveSession(_ session: AuthSession) { + userDefaults.set(session.accessToken, forKey: Key.accessToken) + + if let expiresAt = session.expiresAt { + userDefaults.set(expiresAt.timeIntervalSince1970, forKey: Key.tokenExpDate) + } else { + userDefaults.removeObject(forKey: Key.tokenExpDate) + } + + if let appleUserIdentifier = session.appleUserIdentifier { + userDefaults.set(appleUserIdentifier, forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + } else { + userDefaults.removeObject(forKey: Key.appleUserIdentifier) + userDefaults.set(false, forKey: Key.signedInWithApple) + } + } + + func clearSession() { + userDefaults.removeObject(forKey: Key.accessToken) + userDefaults.removeObject(forKey: Key.tokenExpDate) + userDefaults.removeObject(forKey: Key.appleUserIdentifier) + userDefaults.removeObject(forKey: Key.signedInWithApple) + } + + private func expirationDate() -> Date? { + guard let timestamp = userDefaults.object(forKey: Key.tokenExpDate) as? Double else { + return nil + } + + return Date(timeIntervalSince1970: timestamp) + } +} diff --git a/Siksha/Data/Network/AppVersionRemoteDataSource.swift b/Siksha/Data/Network/AppVersionRemoteDataSource.swift new file mode 100644 index 00000000..04454a08 --- /dev/null +++ b/Siksha/Data/Network/AppVersionRemoteDataSource.swift @@ -0,0 +1,42 @@ +// +// AppVersionRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol AppVersionRemoteDataSource { + func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO +} + +final class AppVersionRemoteDataSourceImpl: AppVersionRemoteDataSource { + private let lookupURL: URL + private let session: URLSession + + init( + lookupURL: URL = URL(string: "https://itunes.apple.com/lookup?id=1032700617")!, + session: URLSession = .shared + ) { + self.lookupURL = lookupURL + self.session = session + } + + func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO { + let (data, response) = try await session.data(from: lookupURL) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.decodingError + } + + guard 200..<300 ~= httpResponse.statusCode else { + throw NetworkError.apiError( + message: "HTTP \(httpResponse.statusCode)", + code: "\(httpResponse.statusCode)" + ) + } + + return try JSONDecoder().decode(AppVersionLookupResponseDTO.self, from: data) + } +} diff --git a/Siksha/Data/Network/AuthRemoteDataSource.swift b/Siksha/Data/Network/AuthRemoteDataSource.swift new file mode 100644 index 00000000..734d50ed --- /dev/null +++ b/Siksha/Data/Network/AuthRemoteDataSource.swift @@ -0,0 +1,44 @@ +// +// AuthRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Alamofire +import Foundation + +protocol AuthRemoteDataSource { + func login(provider: LoginProvider, token: String) async throws -> AuthTokenResponseDTO + func loginForTest() async throws -> AuthTokenResponseDTO + func refreshAccessToken(_ accessToken: String) async throws -> AuthTokenResponseDTO +} + +final class AuthRemoteDataSourceImpl: AuthRemoteDataSource { + func login(provider: LoginProvider, token: String) async throws -> AuthTokenResponseDTO { + try await AF + .request(SikshaAPI.getAccessToken(token: token, endPoint: provider.rawValue)) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } + + func loginForTest() async throws -> AuthTokenResponseDTO { + try await AF + .request(SikshaAPI.testLogin) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthTokenResponseDTO { + var request = try SikshaAPI.refreshAccessToken(token: accessToken).asURLRequest() + request.setToken(token: accessToken) + + return try await AF + .request(request) + .validate() + .serializingDecodable(AuthTokenResponseDTO.self) + .value + } +} diff --git a/Siksha/Data/Network/JWTTokenDecoder.swift b/Siksha/Data/Network/JWTTokenDecoder.swift new file mode 100644 index 00000000..dee0f4b5 --- /dev/null +++ b/Siksha/Data/Network/JWTTokenDecoder.swift @@ -0,0 +1,46 @@ +// +// JWTTokenDecoder.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +struct JWTTokenDecoder { + func expirationDate(from token: String) -> Date? { + guard let exp = decodePayload(token)["exp"] as? Double else { + return nil + } + + return Date(timeIntervalSince1970: exp) + } + + private func decodePayload(_ token: String) -> [String: Any] { + let segments = token.components(separatedBy: ".") + guard segments.count == 3 else { + return [:] + } + + guard let data = base64URLDecode(segments[1]), + let json = try? JSONSerialization.jsonObject(with: data), + let payload = json as? [String: Any] else { + return [:] + } + + return payload + } + + private func base64URLDecode(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let remainder = base64.count % 4 + if remainder > 0 { + base64.append(String(repeating: "=", count: 4 - remainder)) + } + + return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) + } +} diff --git a/Siksha/Data/Network/UserRemoteDataSource.swift b/Siksha/Data/Network/UserRemoteDataSource.swift new file mode 100644 index 00000000..c235f3f9 --- /dev/null +++ b/Siksha/Data/Network/UserRemoteDataSource.swift @@ -0,0 +1,63 @@ +// +// UserRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Alamofire +import Foundation + +protocol UserRemoteDataSource { + func fetchCurrentUser() async throws -> UserDTO + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> UserDTO + func submitVOC(comment: String, platform: String) async throws + func deleteAccount() async throws +} + +final class UserRemoteDataSourceImpl: UserRemoteDataSource { + func fetchCurrentUser() async throws -> UserDTO { + try await AF + .request(SikshaAPI.getUserInfo) + .validate() + .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) + .value + } + + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> UserDTO { + let endpoint = SikshaAPI.updateUserProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + + return try await AF + .upload(multipartFormData: endpoint.multipartFormData!, with: endpoint) + .validate() + .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) + .value + } + + func submitVOC(comment: String, platform: String) async throws { + try await validateNoContentRequest(AF.request(SikshaAPI.submitVOC(comment: comment, platform: platform))) + } + + func deleteAccount() async throws { + try await validateNoContentRequest(AF.request(SikshaAPI.deleteUser)) + } + + private func validateNoContentRequest(_ request: DataRequest) async throws { + _ = try await request + .validate() + .serializingData(emptyResponseCodes: [200, 201, 204]) + .value + } +} diff --git a/Siksha/Data/Repository/AppVersionRepositoryImpl.swift b/Siksha/Data/Repository/AppVersionRepositoryImpl.swift new file mode 100644 index 00000000..c77a8ea5 --- /dev/null +++ b/Siksha/Data/Repository/AppVersionRepositoryImpl.swift @@ -0,0 +1,25 @@ +// +// AppVersionRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class AppVersionRepositoryImpl: AppVersionRepositoryProtocol { + private let remote: AppVersionRemoteDataSource + + init(remote: AppVersionRemoteDataSource) { + self.remote = remote + } + + func fetchLatestAppStoreVersion() async throws -> AppVersion { + guard let version = try await remote.fetchLatestAppStoreVersion().results.first?.version, + !version.isEmpty else { + throw NetworkError.decodingError + } + + return AppVersion(rawValue: version) + } +} diff --git a/Siksha/Data/Repository/AuthRepositoryImpl.swift b/Siksha/Data/Repository/AuthRepositoryImpl.swift new file mode 100644 index 00000000..8449800f --- /dev/null +++ b/Siksha/Data/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,72 @@ +// +// AuthRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class AuthRepositoryImpl: AuthRepositoryProtocol { + private let remote: AuthRemoteDataSource + private let local: AuthSessionLocalDataSource + private let tokenDecoder: JWTTokenDecoder + + init( + remote: AuthRemoteDataSource, + local: AuthSessionLocalDataSource, + tokenDecoder: JWTTokenDecoder = JWTTokenDecoder() + ) { + self.remote = remote + self.local = local + self.tokenDecoder = tokenDecoder + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + let response = try await remote.login( + provider: credential.provider, + token: credential.token + ) + + return makeSession( + accessToken: response.accessToken, + appleUserIdentifier: credential.appleUserIdentifier + ) + } + + func loginForTest() async throws -> AuthSession { + let response = try await remote.loginForTest() + return makeSession(accessToken: response.accessToken) + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + let response = try await remote.refreshAccessToken(accessToken) + return makeSession( + accessToken: response.accessToken, + appleUserIdentifier: local.loadSession()?.appleUserIdentifier + ) + } + + func loadSession() -> AuthSession? { + local.loadSession() + } + + func saveSession(_ session: AuthSession) { + local.saveSession(session) + } + + func clearSession() { + local.clearSession() + } + + private func makeSession( + accessToken: String, + appleUserIdentifier: String? = nil + ) -> AuthSession { + AuthSession( + accessToken: accessToken, + expiresAt: tokenDecoder.expirationDate(from: accessToken), + appleUserIdentifier: appleUserIdentifier + ) + } +} diff --git a/Siksha/Data/Repository/UserRepositoryImpl.swift b/Siksha/Data/Repository/UserRepositoryImpl.swift new file mode 100644 index 00000000..59790ab4 --- /dev/null +++ b/Siksha/Data/Repository/UserRepositoryImpl.swift @@ -0,0 +1,56 @@ +// +// UserRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +final class UserRepositoryImpl: UserRepositoryProtocol { + private let remote: UserRemoteDataSource + + init(remote: UserRemoteDataSource) { + self.remote = remote + } + + func fetchCurrentUser() async throws -> User { + try await remote.fetchCurrentUser().toDomain() + } + + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + try await remote + .updateProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + .toDomain() + } + + func submitVOC(comment: String, platform: String) async throws { + try await remote.submitVOC(comment: comment, platform: platform) + } + + func deleteAccount() async throws { + try await remote.deleteAccount() + } +} + +private extension UserDTO { + func toDomain() -> User { + User( + id: id, + type: type, + identity: identity, + nickname: nickname, + profileUrl: profileUrl, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} From fc78d44591432de2bdd57538cc5880b3ed4c3c5a Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 05:29:36 +0900 Subject: [PATCH 03/14] refactor: add login/user/app-version use cases --- .../FetchAppStoreVersionUseCase.swift | 22 ++++++++++ Siksha/Domain/UseCase/Auth/LoginUseCase.swift | 31 ++++++++++++++ .../Auth/RefreshAccessTokenUseCase.swift | 28 +++++++++++++ .../User/FetchCurrentUserUseCase.swift | 22 ++++++++++ .../UseCase/User/SubmitVOCUseCase.swift | 22 ++++++++++ .../User/UpdateUserProfileUseCase.swift | 36 ++++++++++++++++ Siksha/System/DI/UseCaseProvider.swift | 41 +++++++++++++++++++ 7 files changed, 202 insertions(+) create mode 100644 Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift create mode 100644 Siksha/Domain/UseCase/Auth/LoginUseCase.swift create mode 100644 Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift create mode 100644 Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift diff --git a/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift b/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift new file mode 100644 index 00000000..9a3d6e3c --- /dev/null +++ b/Siksha/Domain/UseCase/AppVersion/FetchAppStoreVersionUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchAppStoreVersionUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol FetchAppStoreVersionUseCase { + func execute() async throws -> AppVersion +} + +final class DefaultFetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase { + private let repository: AppVersionRepositoryProtocol + + init(repository: AppVersionRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> AppVersion { + try await repository.fetchLatestAppStoreVersion() + } +} diff --git a/Siksha/Domain/UseCase/Auth/LoginUseCase.swift b/Siksha/Domain/UseCase/Auth/LoginUseCase.swift new file mode 100644 index 00000000..6c7c2a97 --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/LoginUseCase.swift @@ -0,0 +1,31 @@ +// +// LoginUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol LoginUseCase { + func login(with credential: LoginCredential) async throws -> AuthSession + func loginForTest() async throws -> AuthSession +} + +final class DefaultLoginUseCase: LoginUseCase { + private let repository: AuthRepositoryProtocol + + init(repository: AuthRepositoryProtocol) { + self.repository = repository + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + let session = try await repository.login(with: credential) + repository.saveSession(session) + return session + } + + func loginForTest() async throws -> AuthSession { + let session = try await repository.loginForTest() + repository.saveSession(session) + return session + } +} diff --git a/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift b/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift new file mode 100644 index 00000000..efa0a76c --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/RefreshAccessTokenUseCase.swift @@ -0,0 +1,28 @@ +// +// RefreshAccessTokenUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol RefreshAccessTokenUseCase { + func execute() async throws -> AuthSession? +} + +final class DefaultRefreshAccessTokenUseCase: RefreshAccessTokenUseCase { + private let repository: AuthRepositoryProtocol + + init(repository: AuthRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> AuthSession? { + guard let currentSession = repository.loadSession() else { + return nil + } + + let refreshedSession = try await repository.refreshAccessToken(currentSession.accessToken) + repository.saveSession(refreshedSession) + return refreshedSession + } +} diff --git a/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift b/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift new file mode 100644 index 00000000..065a2dd5 --- /dev/null +++ b/Siksha/Domain/UseCase/User/FetchCurrentUserUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchCurrentUserUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol FetchCurrentUserUseCase { + func execute() async throws -> User +} + +final class DefaultFetchCurrentUserUseCase: FetchCurrentUserUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute() async throws -> User { + try await repository.fetchCurrentUser() + } +} diff --git a/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift b/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift new file mode 100644 index 00000000..0e5d823c --- /dev/null +++ b/Siksha/Domain/UseCase/User/SubmitVOCUseCase.swift @@ -0,0 +1,22 @@ +// +// SubmitVOCUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol SubmitVOCUseCase { + func execute(comment: String, platform: String) async throws +} + +final class DefaultSubmitVOCUseCase: SubmitVOCUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute(comment: String, platform: String) async throws { + try await repository.submitVOC(comment: comment, platform: platform) + } +} diff --git a/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift b/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift new file mode 100644 index 00000000..f53536de --- /dev/null +++ b/Siksha/Domain/UseCase/User/UpdateUserProfileUseCase.swift @@ -0,0 +1,36 @@ +// +// UpdateUserProfileUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol UpdateUserProfileUseCase { + func execute( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User +} + +final class DefaultUpdateUserProfileUseCase: UpdateUserProfileUseCase { + private let repository: UserRepositoryProtocol + + init(repository: UserRepositoryProtocol) { + self.repository = repository + } + + func execute( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + try await repository.updateProfile( + nickname: nickname, + image: image, + changeToDefaultImage: changeToDefaultImage + ) + } +} diff --git a/Siksha/System/DI/UseCaseProvider.swift b/Siksha/System/DI/UseCaseProvider.swift index c5dac739..764b30cb 100644 --- a/Siksha/System/DI/UseCaseProvider.swift +++ b/Siksha/System/DI/UseCaseProvider.swift @@ -6,6 +6,12 @@ // final class UseCaseProvider { + let loginUseCase: LoginUseCase + let refreshAccessTokenUseCase: RefreshAccessTokenUseCase + let fetchCurrentUserUseCase: FetchCurrentUserUseCase + let updateUserProfileUseCase: UpdateUserProfileUseCase + let submitVOCUseCase: SubmitVOCUseCase + let fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase let manageMenuFiltersUseCase: ManageMenuFiltersUseCase let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase let manageFestivalPreferencesUseCase: ManageFestivalPreferencesUseCase @@ -38,6 +44,12 @@ final class UseCaseProvider { let updateMenuAlarmTimeUseCase: UpdateMenuAlarmTimeUseCase init( + loginUseCase: LoginUseCase? = nil, + refreshAccessTokenUseCase: RefreshAccessTokenUseCase? = nil, + fetchCurrentUserUseCase: FetchCurrentUserUseCase? = nil, + updateUserProfileUseCase: UpdateUserProfileUseCase? = nil, + submitVOCUseCase: SubmitVOCUseCase? = nil, + fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase? = nil, manageMenuFiltersUseCase: ManageMenuFiltersUseCase? = nil, manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase? = nil, manageFestivalPreferencesUseCase: ManageFestivalPreferencesUseCase? = nil, @@ -99,7 +111,36 @@ final class UseCaseProvider { remote: MyLikedMenuRemoteDataSourceImpl(), local: UserDefaultsMenuAlarmLocalDataSource() ) + let authSessionLocalDataSource = AuthSessionLocalDataSourceImpl() + let authRepository = AuthRepositoryImpl( + remote: AuthRemoteDataSourceImpl(), + local: authSessionLocalDataSource + ) + let userRepository = UserRepositoryImpl( + remote: UserRemoteDataSourceImpl() + ) + let appVersionRepository = AppVersionRepositoryImpl( + remote: AppVersionRemoteDataSourceImpl() + ) + self.loginUseCase = loginUseCase ?? DefaultLoginUseCase( + repository: authRepository + ) + self.refreshAccessTokenUseCase = refreshAccessTokenUseCase ?? DefaultRefreshAccessTokenUseCase( + repository: authRepository + ) + self.fetchCurrentUserUseCase = fetchCurrentUserUseCase ?? DefaultFetchCurrentUserUseCase( + repository: userRepository + ) + self.updateUserProfileUseCase = updateUserProfileUseCase ?? DefaultUpdateUserProfileUseCase( + repository: userRepository + ) + self.submitVOCUseCase = submitVOCUseCase ?? DefaultSubmitVOCUseCase( + repository: userRepository + ) + self.fetchAppStoreVersionUseCase = fetchAppStoreVersionUseCase ?? DefaultFetchAppStoreVersionUseCase( + repository: appVersionRepository + ) self.manageMenuFiltersUseCase = manageMenuFiltersUseCase ?? DefaultManageMenuFiltersUseCase( repository: userPreferenceRepository ) From b342181cae461234e6bc4293e5ed3f054923ffd2 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 5 Jul 2026 21:50:04 +0900 Subject: [PATCH 04/14] refactor(auth): extract social login handling into SocialLoginService --- Siksha/Data/External/SocialLoginService.swift | 213 ++++++++++++++++++ Siksha/Presentation/LoginView.swift | 108 ++++----- Siksha/Presentation/LoginViewModel.swift | 162 +++++-------- Siksha/System/DI/AppContainer.swift | 2 + Siksha/System/SceneDelegate.swift | 9 +- 5 files changed, 316 insertions(+), 178 deletions(-) create mode 100644 Siksha/Data/External/SocialLoginService.swift diff --git a/Siksha/Data/External/SocialLoginService.swift b/Siksha/Data/External/SocialLoginService.swift new file mode 100644 index 00000000..c6e78bd2 --- /dev/null +++ b/Siksha/Data/External/SocialLoginService.swift @@ -0,0 +1,213 @@ +// +// SocialLoginService.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import AuthenticationServices +import Foundation +import GoogleSignIn +import KakaoSDKAuth +import KakaoSDKUser +import UIKit + +protocol SocialLoginService: AnyObject { + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async throws -> LoginCredential + @MainActor + func handleOpenURL(_ url: URL) -> Bool +} + +enum SocialLoginServiceError: Error { + case missingPresentingViewController + case missingCredential +} + +final class SocialLoginServiceImpl: SocialLoginService { + private var appleAuthorizationCoordinator: AppleAuthorizationCoordinator? + + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + switch provider { + case .kakao: + return try await loginWithKakao() + case .google: + return try await loginWithGoogle(presentingViewController: presentingViewController) + case .apple: + return try await loginWithApple(presentingViewController: presentingViewController) + } + } + + @MainActor + func handleOpenURL(_ url: URL) -> Bool { + if AuthApi.isKakaoTalkLoginUrl(url) { + return AuthController.handleOpenUrl(url: url) + } + + return GIDSignIn.sharedInstance.handle(url) + } + + private func loginWithKakao() async throws -> LoginCredential { + let oauthToken = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let completion: (OAuthToken?, Error?) -> Void = { oauthToken, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let oauthToken else { + continuation.resume(throwing: SocialLoginServiceError.missingCredential) + return + } + + continuation.resume(returning: oauthToken) + } + + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: completion) + } else { + UserApi.shared.loginWithKakaoAccount(completion: completion) + } + } + + return LoginCredential(provider: .kakao, token: oauthToken.accessToken) + } + + private func loginWithGoogle( + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + guard let presentingViewController else { + throw SocialLoginServiceError.missingPresentingViewController + } + + let signInResult = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { signInResult, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let signInResult else { + continuation.resume(throwing: SocialLoginServiceError.missingCredential) + return + } + + continuation.resume(returning: signInResult) + } + } + + guard let token = signInResult.user.idToken?.tokenString else { + throw SocialLoginServiceError.missingCredential + } + + return LoginCredential(provider: .google, token: token) + } + + private func loginWithApple( + presentingViewController: UIViewController? + ) async throws -> LoginCredential { + let coordinator = AppleAuthorizationCoordinator( + presentingViewController: presentingViewController + ) + appleAuthorizationCoordinator = coordinator + defer { + appleAuthorizationCoordinator = nil + } + + return try await coordinator.perform() + } +} + +private final class AppleAuthorizationCoordinator: NSObject { + private weak var presentingViewController: UIViewController? + private var continuation: CheckedContinuation? + + init(presentingViewController: UIViewController?) { + self.presentingViewController = presentingViewController + } + + func perform() async throws -> LoginCredential { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.fullName, .email] + + let authorizationController = ASAuthorizationController( + authorizationRequests: [request] + ) + authorizationController.delegate = self + authorizationController.presentationContextProvider = self + authorizationController.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { + return + } + + self.continuation = nil + + switch result { + case .success(let credential): + continuation.resume(returning: credential) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +extension AppleAuthorizationCoordinator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = appleIDCredential.identityToken, + let token = String(data: tokenData, encoding: .utf8) else { + complete(with: .failure(SocialLoginServiceError.missingCredential)) + return + } + + complete( + with: .success( + LoginCredential( + provider: .apple, + token: token, + appleUserIdentifier: appleIDCredential.user + ) + ) + ) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + complete(with: .failure(error)) + } +} + +extension AppleAuthorizationCoordinator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + if let window = presentingViewController?.view.window { + return window + } + + if let keyWindow = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: \.isKeyWindow) { + return keyWindow + } + + return ASPresentationAnchor() + } +} diff --git a/Siksha/Presentation/LoginView.swift b/Siksha/Presentation/LoginView.swift index f15fce54..d322ab30 100644 --- a/Siksha/Presentation/LoginView.swift +++ b/Siksha/Presentation/LoginView.swift @@ -6,67 +6,74 @@ // import SwiftUI -import AuthenticationServices -import KakaoSDKAuth -import KakaoSDKUser -import GoogleSignIn +import UIKit struct LoginView: View { @Environment(\.viewController) private var viewControllerHolder: UIViewController? - - @ObservedObject var viewModel = LoginViewModel() - + + @StateObject private var viewModel: LoginViewModel + + init( + viewModel: LoginViewModel = LoginViewModel( + loginUseCase: AppContainer.shared.useCases.loginUseCase, + socialLoginService: AppContainer.shared.socialLoginService + ) + ) { + _viewModel = StateObject(wrappedValue: viewModel) + } + var body: some View { GeometryReader { geometry in ZStack { VStack { Spacer() - + Image(.Logos.sikshaSplash) .resizable() .frame(width: 85.5, height: 49.5) - + Spacer() - + VStack(spacing: 10) { - Button(action : { - handleKakaoLogin() - }){ + Button(action: { + login(provider: .kakao) + }) { Image(.Images.Login.kakaoButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) } - - Button(action : { - handleGoogleLogin() - }){ + + Button(action: { + login(provider: .google) + }) { Image(.Images.Login.googleButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) } - + Button(action: { - handleAppleLogin() + login(provider: .apple) }, label: { Image(.Images.Login.appleButton) .frame(width: 300, height: 45) .foregroundColor(.black) .cornerRadius(5.5) }) - + #if DEBUG - + Button(action: { - viewModel.requestTestLogin() + loginForTest() }, label: { Text("테스트 로그인") }) - + #endif } - + .disabled(viewModel.isLoggingIn) + Spacer() } } @@ -82,50 +89,23 @@ struct LoginView: View { } .edgesIgnoringSafeArea(.all) } - - func handleAppleLogin() { - let appleIDProvider = ASAuthorizationAppleIDProvider() - let request = appleIDProvider.createRequest() - request.requestedScopes = [.fullName, .email] - - let authorizationController = ASAuthorizationController(authorizationRequests: [request]) - - authorizationController.delegate = viewModel - authorizationController.performRequests() - } - - func handleKakaoLogin() { - // checks whether KakaoTalk is installed - if (UserApi.isKakaoTalkLoginAvailable()) { - UserApi.shared.loginWithKakaoTalk(completion: self.handleKakaoLoginResponse) - } else { - // Login through Safari - UserApi.shared.loginWithKakaoAccount(completion: self.handleKakaoLoginResponse) - } - } - - func handleKakaoLoginResponse(oauthToken: OAuthToken?, error: Error?) { - if let oauthToken = oauthToken { - viewModel.kakaoIdToken = oauthToken.accessToken - } else { - viewModel.signInFailed = true + + private func login(provider: LoginProvider) { + Task { + await viewModel.login( + provider: provider, + presentingViewController: viewControllerHolder + ) } } - - func handleGoogleLogin() { - if let viewController = viewControllerHolder { - GIDSignIn.sharedInstance.signIn(withPresenting: viewController) { signInResult, error in - guard let result = signInResult, - let token = result.user.idToken?.tokenString else { - viewModel.signInFailed = true - return - } - viewModel.googleIdToken = token - } + + private func loginForTest() { + Task { + await viewModel.loginForTest() } } - - func presentMenu() { + + private func presentMenu() { let appState = AppState() viewControllerHolder?.present(style: .fullScreen) { ContentView().environmentObject(appState) diff --git a/Siksha/Presentation/LoginViewModel.swift b/Siksha/Presentation/LoginViewModel.swift index 187f25b0..7c94b670 100644 --- a/Siksha/Presentation/LoginViewModel.swift +++ b/Siksha/Presentation/LoginViewModel.swift @@ -6,117 +6,67 @@ // import Foundation -import SwiftyJSON -import GoogleSignIn -import AuthenticationServices -import Combine +import UIKit + +final class LoginViewModel: ObservableObject { + private let loginUseCase: LoginUseCase + private let socialLoginService: SocialLoginService -@MainActor -class LoginViewModel: NSObject, ObservableObject, ASAuthorizationControllerDelegate { - private var cancellables = Set() - - @Published var kakaoIdToken: String? = nil - @Published var googleIdToken: String? = nil @Published var signInFailed: Bool = false - + @Published private(set) var isLoggingIn: Bool = false + var onSignedIn: () -> Void = {} - - override init() { - super.init() - - $kakaoIdToken - .sink { [weak self] token in - guard let self = self, let token = token else { - return - } - - self.authenticateWithKakao(token: token) - } - .store(in: &cancellables) - - $googleIdToken - .sink { [weak self] token in - guard let self = self, let token = token else { - return - } - - self.authenticateWithGoogle(idToken: token) - } - .store(in: &cancellables) - } - - func requestTokenToSikshaAPI(token: String, endPoint: String) { - #if DEBUG - print(token) - #endif - - Networking.shared.getAccessToken(token: token, endPoint: endPoint) - .sink { result in - guard let data = result.value, - let accessToken = try? JSON(data: data)["access_token"].stringValue, - let expDate = Utils.shared.decode(jwtToken: accessToken)["exp"] as? Double else { - self.signInFailed = true - return - } - - #if DEBUG - print(expDate) - print(accessToken) - #endif - - UserDefaults.standard.set(expDate, forKey: "tokenExpDate") - UserDefaults.standard.set(accessToken, forKey: "accessToken") - - self.onSignedIn() - } - .store(in: &cancellables) - } - - func requestTestLogin() { - Networking.shared.testLogin() - .sink { result in - guard let data = result.value, - let accessToken = try? JSON(data: data)["access_token"].stringValue else { - self.signInFailed = true - return - } - - UserDefaults.standard.set(accessToken, forKey: "accessToken") - - self.onSignedIn() - } - .store(in: &cancellables) - } - - // MARK: - Google Sign in - - func authenticateWithGoogle(idToken: String) { - requestTokenToSikshaAPI(token: idToken, endPoint: "google") + + init( + loginUseCase: LoginUseCase, + socialLoginService: SocialLoginService + ) { + self.loginUseCase = loginUseCase + self.socialLoginService = socialLoginService } - - // MARK: - Kakao Sign in - - func authenticateWithKakao(token: String) { - requestTokenToSikshaAPI(token: token, endPoint: "kakao") + + @MainActor + func login( + provider: LoginProvider, + presentingViewController: UIViewController? + ) async { + guard !isLoggingIn else { + return + } + + isLoggingIn = true + defer { + isLoggingIn = false + } + + do { + let credential = try await socialLoginService.login( + provider: provider, + presentingViewController: presentingViewController + ) + _ = try await loginUseCase.login(with: credential) + onSignedIn() + } catch { + signInFailed = true + } } - - // MARK: - Apple Sign in - - func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { - switch authorization.credential { - case let appleIDCredential as ASAuthorizationAppleIDCredential: - // Create an account in your system. - let userIdentifier = appleIDCredential.user - let token = appleIDCredential.identityToken - - UserDefaults.standard.set(userIdentifier, forKey: "appleUserIdentifier") - UserDefaults.standard.set(true, forKey: "signedInWithApple") - - print(String(decoding: token!, as: UTF8.self)) - - requestTokenToSikshaAPI(token: String(decoding: token!, as: UTF8.self), endPoint: "apple") - default: - break + + @MainActor + func loginForTest() async { + guard !isLoggingIn else { + return + } + + isLoggingIn = true + defer { + isLoggingIn = false + } + + do { + _ = try await loginUseCase.loginForTest() + onSignedIn() + } catch { + signInFailed = true } } } diff --git a/Siksha/System/DI/AppContainer.swift b/Siksha/System/DI/AppContainer.swift index c39f57d8..a8c1d540 100644 --- a/Siksha/System/DI/AppContainer.swift +++ b/Siksha/System/DI/AppContainer.swift @@ -10,6 +10,7 @@ final class AppContainer { let domain: RepositoryProvider let useCases: UseCaseProvider + let socialLoginService: SocialLoginService let menuAlarmNotificationManager: DefaultMenuAlarmNotificationManager init() { @@ -19,6 +20,7 @@ final class AppContainer { self.domain = domain self.useCases = UseCaseProvider() + self.socialLoginService = SocialLoginServiceImpl() self.menuAlarmNotificationManager = DefaultMenuAlarmNotificationManager( authRepository: domain.authRepository ) diff --git a/Siksha/System/SceneDelegate.swift b/Siksha/System/SceneDelegate.swift index 8dccba77..50c103bc 100644 --- a/Siksha/System/SceneDelegate.swift +++ b/Siksha/System/SceneDelegate.swift @@ -8,8 +8,6 @@ import UIKit import SwiftUI import AuthenticationServices -import KakaoSDKAuth -import GoogleSignIn class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -17,11 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { if let url = URLContexts.first?.url { - if (AuthApi.isKakaoTalkLoginUrl(url)) { - _ = AuthController.handleOpenUrl(url: url) - } else { - _ = GIDSignIn.sharedInstance.handle(url) - } + _ = AppContainer.shared.socialLoginService.handleOpenURL(url) } } @@ -105,4 +99,3 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } - From 987fbb7a63b9a0f4357cdcd71920ab6c36334556 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 12 Jul 2026 13:29:01 +0900 Subject: [PATCH 05/14] refactor: seperate Apple Login Usecase --- .../External/AppleCredentialService.swift | 42 ++++++++++ .../AppleCredentialRepositoryImpl.swift | 18 +++++ .../Domain/Models/AppleCredentialStatus.swift | 13 ++++ .../AppleCredentialRepositoryProtocol.swift | 10 +++ .../Auth/ResolveInitialAuthStateUseCase.swift | 78 +++++++++++++++++++ Siksha/System/AppState.swift | 38 +-------- Siksha/System/DI/UseCaseProvider.swift | 15 +++- Siksha/System/SceneDelegate.swift | 68 ++++++++-------- 8 files changed, 209 insertions(+), 73 deletions(-) create mode 100644 Siksha/Data/External/AppleCredentialService.swift create mode 100644 Siksha/Data/Repository/AppleCredentialRepositoryImpl.swift create mode 100644 Siksha/Domain/Models/AppleCredentialStatus.swift create mode 100644 Siksha/Domain/Repository/AppleCredentialRepositoryProtocol.swift create mode 100644 Siksha/Domain/UseCase/Auth/ResolveInitialAuthStateUseCase.swift diff --git a/Siksha/Data/External/AppleCredentialService.swift b/Siksha/Data/External/AppleCredentialService.swift new file mode 100644 index 00000000..0491d579 --- /dev/null +++ b/Siksha/Data/External/AppleCredentialService.swift @@ -0,0 +1,42 @@ +// +// AppleCredentialService.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import AuthenticationServices + +protocol AppleCredentialService { + func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus +} + +final class AppleCredentialServiceImpl: AppleCredentialService { + private let provider: ASAuthorizationAppleIDProvider + + init(provider: ASAuthorizationAppleIDProvider = ASAuthorizationAppleIDProvider()) { + self.provider = provider + } + + func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus { + await withCheckedContinuation { continuation in + provider.getCredentialState(forUserID: userIdentifier) { credentialState, error in + guard error == nil else { + continuation.resume(returning: .unknown) + return + } + + switch credentialState { + case .authorized: + continuation.resume(returning: .authorized) + case .revoked: + continuation.resume(returning: .revoked) + case .notFound: + continuation.resume(returning: .notFound) + default: + continuation.resume(returning: .unknown) + } + } + } + } +} diff --git a/Siksha/Data/Repository/AppleCredentialRepositoryImpl.swift b/Siksha/Data/Repository/AppleCredentialRepositoryImpl.swift new file mode 100644 index 00000000..0ccaa866 --- /dev/null +++ b/Siksha/Data/Repository/AppleCredentialRepositoryImpl.swift @@ -0,0 +1,18 @@ +// +// AppleCredentialRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +final class AppleCredentialRepositoryImpl: AppleCredentialRepositoryProtocol { + private let service: AppleCredentialService + + init(service: AppleCredentialService) { + self.service = service + } + + func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus { + await service.credentialStatus(for: userIdentifier) + } +} diff --git a/Siksha/Domain/Models/AppleCredentialStatus.swift b/Siksha/Domain/Models/AppleCredentialStatus.swift new file mode 100644 index 00000000..12cac65f --- /dev/null +++ b/Siksha/Domain/Models/AppleCredentialStatus.swift @@ -0,0 +1,13 @@ +// +// AppleCredentialStatus.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +enum AppleCredentialStatus { + case authorized + case revoked + case notFound + case unknown +} diff --git a/Siksha/Domain/Repository/AppleCredentialRepositoryProtocol.swift b/Siksha/Domain/Repository/AppleCredentialRepositoryProtocol.swift new file mode 100644 index 00000000..d47347ab --- /dev/null +++ b/Siksha/Domain/Repository/AppleCredentialRepositoryProtocol.swift @@ -0,0 +1,10 @@ +// +// AppleCredentialRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +protocol AppleCredentialRepositoryProtocol { + func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus +} diff --git a/Siksha/Domain/UseCase/Auth/ResolveInitialAuthStateUseCase.swift b/Siksha/Domain/UseCase/Auth/ResolveInitialAuthStateUseCase.swift new file mode 100644 index 00000000..86c906df --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/ResolveInitialAuthStateUseCase.swift @@ -0,0 +1,78 @@ +// +// ResolveInitialAuthStateUseCase.swift +// Siksha +// +// Created by Codex on 7/5/26. +// + +import Foundation + +protocol ResolveInitialAuthStateUseCase { + func execute() async -> AuthState +} + +final class DefaultResolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase { + private let authRepository: AuthRepositoryProtocol + private let refreshAccessTokenUseCase: RefreshAccessTokenUseCase + private let appleCredentialRepository: AppleCredentialRepositoryProtocol + private let refreshWindow: TimeInterval + private let now: () -> Date + + init( + authRepository: AuthRepositoryProtocol, + refreshAccessTokenUseCase: RefreshAccessTokenUseCase, + appleCredentialRepository: AppleCredentialRepositoryProtocol, + refreshWindow: TimeInterval = 15_552_000, + now: @escaping () -> Date = { Date() } + ) { + self.authRepository = authRepository + self.refreshAccessTokenUseCase = refreshAccessTokenUseCase + self.appleCredentialRepository = appleCredentialRepository + self.refreshWindow = refreshWindow + self.now = now + } + + func execute() async -> AuthState { + guard let session = authRepository.loadSession() else { + return .requiresLogin + } + + let currentDate = now() + + guard !session.isExpired(asOf: currentDate) else { + authRepository.clearSession() + return .requiresLogin + } + + if let appleUserIdentifier = session.appleUserIdentifier { + let credentialStatus = await appleCredentialRepository.credentialStatus( + for: appleUserIdentifier + ) + + switch credentialStatus { + case .revoked, .notFound: + authRepository.clearSession() + return .requiresLogin + case .authorized, .unknown: + break + } + } + + guard session.needsRefresh( + asOf: currentDate, + refreshWindow: refreshWindow + ) else { + return .authenticated(session) + } + + do { + guard let refreshedSession = try await refreshAccessTokenUseCase.execute() else { + return .authenticated(session) + } + return .authenticated(refreshedSession) + } catch { + authRepository.clearSession() + return .requiresLogin + } + } +} diff --git a/Siksha/System/AppState.swift b/Siksha/System/AppState.swift index 0e33d581..e2d76c93 100644 --- a/Siksha/System/AppState.swift +++ b/Siksha/System/AppState.swift @@ -5,40 +5,6 @@ // // Created by 박종석 on 2021/02/02. // -import Foundation -import SwiftyJSON -import Combine +import SwiftUI -public class AppState: ObservableObject { - private var cancellables = Set() - - init(){ - let token = UserDefaults.standard.string(forKey: "accessToken") - let exp = UserDefaults.standard.double(forKey: "tokenExpDate") - - let expDate = Date(timeIntervalSince1970: exp) - - if expDate >= Date(), DateInterval(start: Date(), end: expDate).duration < TimeInterval(15552000), let token = token { // 6 month - Networking.shared.refreshAccessToken(token: token) - .receive(on: RunLoop.main) - .sink { _ in } - receiveValue: { response in - guard let data = response.value, - let accessToken = try? JSON(data: data)["access_token"].stringValue, - let expDate = Utils.shared.decode(jwtToken: accessToken)["exp"] as? Double else { - return - } - - #if DEBUG - print(expDate) - print(accessToken) - #endif - - UserDefaults.standard.set(expDate, forKey: "tokenExpDate") - UserDefaults.standard.set(accessToken, forKey: "accessToken") - - } - .store(in: &cancellables) - } - } -} +public final class AppState: ObservableObject {} diff --git a/Siksha/System/DI/UseCaseProvider.swift b/Siksha/System/DI/UseCaseProvider.swift index 764b30cb..faa7b398 100644 --- a/Siksha/System/DI/UseCaseProvider.swift +++ b/Siksha/System/DI/UseCaseProvider.swift @@ -8,6 +8,7 @@ final class UseCaseProvider { let loginUseCase: LoginUseCase let refreshAccessTokenUseCase: RefreshAccessTokenUseCase + let resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase let fetchCurrentUserUseCase: FetchCurrentUserUseCase let updateUserProfileUseCase: UpdateUserProfileUseCase let submitVOCUseCase: SubmitVOCUseCase @@ -46,6 +47,7 @@ final class UseCaseProvider { init( loginUseCase: LoginUseCase? = nil, refreshAccessTokenUseCase: RefreshAccessTokenUseCase? = nil, + resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase? = nil, fetchCurrentUserUseCase: FetchCurrentUserUseCase? = nil, updateUserProfileUseCase: UpdateUserProfileUseCase? = nil, submitVOCUseCase: SubmitVOCUseCase? = nil, @@ -116,18 +118,27 @@ final class UseCaseProvider { remote: AuthRemoteDataSourceImpl(), local: authSessionLocalDataSource ) + let appleCredentialRepository = AppleCredentialRepositoryImpl( + service: AppleCredentialServiceImpl() + ) let userRepository = UserRepositoryImpl( remote: UserRemoteDataSourceImpl() ) let appVersionRepository = AppVersionRepositoryImpl( remote: AppVersionRemoteDataSourceImpl() ) + let defaultRefreshAccessTokenUseCase = refreshAccessTokenUseCase ?? DefaultRefreshAccessTokenUseCase( + repository: authRepository + ) self.loginUseCase = loginUseCase ?? DefaultLoginUseCase( repository: authRepository ) - self.refreshAccessTokenUseCase = refreshAccessTokenUseCase ?? DefaultRefreshAccessTokenUseCase( - repository: authRepository + self.refreshAccessTokenUseCase = defaultRefreshAccessTokenUseCase + self.resolveInitialAuthStateUseCase = resolveInitialAuthStateUseCase ?? DefaultResolveInitialAuthStateUseCase( + authRepository: authRepository, + refreshAccessTokenUseCase: defaultRefreshAccessTokenUseCase, + appleCredentialRepository: appleCredentialRepository ) self.fetchCurrentUserUseCase = fetchCurrentUserUseCase ?? DefaultFetchCurrentUserUseCase( repository: userRepository diff --git a/Siksha/System/SceneDelegate.swift b/Siksha/System/SceneDelegate.swift index 50c103bc..75febafb 100644 --- a/Siksha/System/SceneDelegate.swift +++ b/Siksha/System/SceneDelegate.swift @@ -7,7 +7,6 @@ import UIKit import SwiftUI -import AuthenticationServices class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -26,46 +25,45 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Create the SwiftUI view that provides the window contents. - let appleUserIdentifier = UserDefaults.standard.string(forKey: "appleUserIdentifier") - var accessToken = UserDefaults.standard.string(forKey: "accessToken") - - let appleIDProvider = ASAuthorizationAppleIDProvider() - - if UserDefaults.standard.bool(forKey: "signedInWithApple") { - if let identifier = appleUserIdentifier { - appleIDProvider.getCredentialState(forUserID: appleUserIdentifier ?? "") { (credentialState, error) in - switch credentialState { - case .authorized: - break // The Apple ID credential is valid. - case .revoked, .notFound: - accessToken = nil - UserDefaults.standard.removeObject(forKey: "userToken") - default: - break - } - } - } else { - accessToken = nil - UserDefaults.standard.removeObject(forKey: "userToken") - } - } - - // Navigation Bar 배경색 세팅 UINavigationBar.changeBackgroundColor(color: UIColor(named: "Color/Foundation/Orange/500") ?? .clear) - // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) - if accessToken != nil { - print(accessToken!) - let appState = AppState() - let contentView = ContentView().environmentObject(appState) - window.rootViewController = UIHostingController(rootView: contentView) - } else { - window.rootViewController = UIHostingController(rootView: LoginView()) - } + window.rootViewController = makePlaceholderRootViewController() self.window = window window.makeKeyAndVisible() + + Task { [weak self, weak window] in + let authState = await AppContainer.shared.useCases.resolveInitialAuthStateUseCase.execute() + + await MainActor.run { + guard let self, + let window, + self.window === window else { + return + } + + window.rootViewController = self.makeRootViewController(for: authState) + } + } + } + } + + private func makePlaceholderRootViewController() -> UIViewController { + UIHostingController( + rootView: Color("Color/Foundation/Orange/500") + .ignoresSafeArea() + ) + } + + private func makeRootViewController(for authState: AuthState) -> UIViewController { + switch authState { + case .authenticated: + let appState = AppState() + let contentView = ContentView().environmentObject(appState) + return UIHostingController(rootView: contentView) + case .requiresLogin: + return UIHostingController(rootView: LoginView()) } } From bca7e47defbe2c654fbc334119bc5ad0f5768455 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 12 Jul 2026 14:01:34 +0900 Subject: [PATCH 06/14] fix: image assets in LaunchScreen --- .../Presentation/Base.lproj/LaunchScreen.storyboard | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Siksha/Presentation/Base.lproj/LaunchScreen.storyboard b/Siksha/Presentation/Base.lproj/LaunchScreen.storyboard index 31086295..77cb3e83 100644 --- a/Siksha/Presentation/Base.lproj/LaunchScreen.storyboard +++ b/Siksha/Presentation/Base.lproj/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + @@ -17,14 +17,14 @@ - + - + @@ -49,8 +49,8 @@ - - + + From 4298970b4e44d8d812d12266da6e9b1257f5f6f3 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 12 Jul 2026 14:26:54 +0900 Subject: [PATCH 07/14] refactor(auth): centralize app root authentication flow --- Siksha.xcodeproj/project.pbxproj | 1 + Siksha/Presentation/AppRootView.swift | 33 +++++++ Siksha/Presentation/LoginView.swift | 12 +-- .../Settings/AccountManageView.swift | 18 ++-- Siksha/Presentation/StartupView.swift | 38 +++++++++ Siksha/System/AppState.swift | 43 +++++++++- Siksha/System/SceneDelegate.swift | 44 +--------- SikshaTests/AppStateTests.swift | 85 +++++++++++++++++++ 8 files changed, 217 insertions(+), 57 deletions(-) create mode 100644 Siksha/Presentation/AppRootView.swift create mode 100644 Siksha/Presentation/StartupView.swift create mode 100644 SikshaTests/AppStateTests.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index 004184af..7868aa5e 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 3333F90C2F13F2E6004B13CA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + AppStateTests.swift, SikshaTests.swift, ); target = 1E3A56FC25C6FCE900DBB3F4 /* SikshaTests */; diff --git a/Siksha/Presentation/AppRootView.swift b/Siksha/Presentation/AppRootView.swift new file mode 100644 index 00000000..49f0aac6 --- /dev/null +++ b/Siksha/Presentation/AppRootView.swift @@ -0,0 +1,33 @@ +// +// AppRootView.swift +// Siksha +// +// Created by Codex on 7/12/26. +// + +import SwiftUI + +struct AppRootView: View { + @StateObject private var appState: AppState + + init(appState: AppState) { + _appState = StateObject(wrappedValue: appState) + } + + var body: some View { + Group { + switch appState.rootState { + case .resolvingAuth: + StartupView() + case .authenticated: + ContentView() + case .requiresLogin: + LoginView() + } + } + .environmentObject(appState) + .task { + await appState.resolveInitialAuthState() + } + } +} diff --git a/Siksha/Presentation/LoginView.swift b/Siksha/Presentation/LoginView.swift index d322ab30..0c850723 100644 --- a/Siksha/Presentation/LoginView.swift +++ b/Siksha/Presentation/LoginView.swift @@ -10,6 +10,7 @@ import UIKit struct LoginView: View { @Environment(\.viewController) private var viewControllerHolder: UIViewController? + @EnvironmentObject private var appState: AppState @StateObject private var viewModel: LoginViewModel @@ -79,12 +80,12 @@ struct LoginView: View { } .frame(width: geometry.size.width, height: geometry.size.height + geometry.safeAreaInsets.bottom + geometry.safeAreaInsets.top) .padding(.top, -geometry.safeAreaInsets.top) - .background(Color("Color/Foundation/Orange/500")) + .background(Color.orange500) .alert(isPresented: $viewModel.signInFailed, content: { Alert(title: Text("로그인"), message: Text("로그인을 실패했습니다. 다시 시도해주세요."), dismissButton: .default(Text("확인"))) }) .onAppear { - viewModel.onSignedIn = presentMenu + viewModel.onSignedIn = appState.didLogin } } .edgesIgnoringSafeArea(.all) @@ -105,16 +106,11 @@ struct LoginView: View { } } - private func presentMenu() { - let appState = AppState() - viewControllerHolder?.present(style: .fullScreen) { - ContentView().environmentObject(appState) - } - } } struct LoginView_Previews: PreviewProvider { static var previews: some View { LoginView() + .environmentObject(AppState()) } } diff --git a/Siksha/Presentation/Settings/AccountManageView.swift b/Siksha/Presentation/Settings/AccountManageView.swift index 53f91fd8..20e4a5e6 100644 --- a/Siksha/Presentation/Settings/AccountManageView.swift +++ b/Siksha/Presentation/Settings/AccountManageView.swift @@ -6,11 +6,10 @@ // import SwiftUI -import UIKit struct AccountManageView: View { @Environment(\.presentationMode) var presentationMode: Binding - @Environment(\.viewController) private var viewControllerHolder: UIViewController? + @EnvironmentObject private var appState: AppState @ObservedObject var viewModel:RenewalSettingsViewModel init(viewModel: RenewalSettingsViewModel) { @@ -56,9 +55,7 @@ struct AccountManageView: View { .destructive(Text("로그아웃")){ viewModel.logOutAccount(){ result in if result{ - viewControllerHolder?.present(style: .fullScreen) { - LoginView() - } + transitionToLogin() } } }, @@ -87,9 +84,7 @@ struct AccountManageView: View { .destructive(Text("회원 탈퇴")) { viewModel.removeAccount { success in if success { - viewControllerHolder?.present(style: .fullScreen) { - LoginView() - } + transitionToLogin() } } }, @@ -128,6 +123,12 @@ struct AccountManageView: View { .customNavigationBar(title: "계정관리") .navigationBarItems(leading: backButton) } + + private func transitionToLogin() { + Task { @MainActor in + appState.didLogout() + } + } } struct AccountManageView_Previews: PreviewProvider { @@ -138,6 +139,7 @@ struct AccountManageView_Previews: PreviewProvider { manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase ) ) + .environmentObject(AppState()) } } } diff --git a/Siksha/Presentation/StartupView.swift b/Siksha/Presentation/StartupView.swift new file mode 100644 index 00000000..4ea6f8d5 --- /dev/null +++ b/Siksha/Presentation/StartupView.swift @@ -0,0 +1,38 @@ +// +// StartupView.swift +// Siksha +// +// Created by Codex on 7/12/26. +// + +import SwiftUI + +struct StartupView: View { + var body: some View { + ZStack { + Color.orange500 + + Image(.Logos.sikshaSplash) + .resizable() + .scaledToFit() + .frame(width: 85.5, height: 49.5) + + VStack { + Spacer() + + Image(.Logos.wafflestudioTextLogo) + .resizable() + .scaledToFit() + .frame(width: 191, height: 16) + .padding(.bottom, 90) + } + } + .ignoresSafeArea() + } +} + +struct StartupView_Previews: PreviewProvider { + static var previews: some View { + StartupView() + } +} diff --git a/Siksha/System/AppState.swift b/Siksha/System/AppState.swift index e2d76c93..399d1c08 100644 --- a/Siksha/System/AppState.swift +++ b/Siksha/System/AppState.swift @@ -7,4 +7,45 @@ // import SwiftUI -public final class AppState: ObservableObject {} +enum AppRootState: Equatable { + case resolvingAuth + case authenticated + case requiresLogin +} + +@MainActor +public final class AppState: ObservableObject { + @Published private(set) var rootState: AppRootState = .resolvingAuth + + private let resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase + private var didResolveInitialAuthState = false + + init( + resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase = AppContainer.shared.useCases.resolveInitialAuthStateUseCase + ) { + self.resolveInitialAuthStateUseCase = resolveInitialAuthStateUseCase + } + + func resolveInitialAuthState() async { + guard !didResolveInitialAuthState else { + return + } + + didResolveInitialAuthState = true + + switch await resolveInitialAuthStateUseCase.execute() { + case .authenticated: + rootState = .authenticated + case .requiresLogin: + rootState = .requiresLogin + } + } + + func didLogin() { + rootState = .authenticated + } + + func didLogout() { + rootState = .requiresLogin + } +} diff --git a/Siksha/System/SceneDelegate.swift b/Siksha/System/SceneDelegate.swift index 75febafb..92472439 100644 --- a/Siksha/System/SceneDelegate.swift +++ b/Siksha/System/SceneDelegate.swift @@ -19,51 +19,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - - // Create the SwiftUI view that provides the window contents. - - UINavigationBar.changeBackgroundColor(color: UIColor(named: "Color/Foundation/Orange/500") ?? .clear) + UINavigationBar.changeBackgroundColor(color: UIColor(Color.orange500)) if let windowScene = scene as? UIWindowScene { + let appState = AppState() + let rootView = AppRootView(appState: appState) let window = UIWindow(windowScene: windowScene) - window.rootViewController = makePlaceholderRootViewController() + window.rootViewController = UIHostingController(rootView: rootView) self.window = window window.makeKeyAndVisible() - - Task { [weak self, weak window] in - let authState = await AppContainer.shared.useCases.resolveInitialAuthStateUseCase.execute() - - await MainActor.run { - guard let self, - let window, - self.window === window else { - return - } - - window.rootViewController = self.makeRootViewController(for: authState) - } - } - } - } - - private func makePlaceholderRootViewController() -> UIViewController { - UIHostingController( - rootView: Color("Color/Foundation/Orange/500") - .ignoresSafeArea() - ) - } - - private func makeRootViewController(for authState: AuthState) -> UIViewController { - switch authState { - case .authenticated: - let appState = AppState() - let contentView = ContentView().environmentObject(appState) - return UIHostingController(rootView: contentView) - case .requiresLogin: - return UIHostingController(rootView: LoginView()) } } diff --git a/SikshaTests/AppStateTests.swift b/SikshaTests/AppStateTests.swift new file mode 100644 index 00000000..7f8903bf --- /dev/null +++ b/SikshaTests/AppStateTests.swift @@ -0,0 +1,85 @@ +// +// AppStateTests.swift +// SikshaTests +// +// Created by Codex on 7/12/26. +// + +import XCTest +@testable import Siksha + +@MainActor +final class AppStateTests: XCTestCase { + func testInitialStateIsResolvingAuth() { + let appState = AppState( + resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCaseStub( + result: .requiresLogin + ) + ) + + XCTAssertEqual(appState.rootState, .resolvingAuth) + } + + func testResolveInitialAuthStateMapsAuthenticatedResult() async { + let session = AuthSession( + accessToken: "access-token", + expiresAt: nil + ) + let useCase = ResolveInitialAuthStateUseCaseStub( + result: .authenticated(session) + ) + let appState = AppState(resolveInitialAuthStateUseCase: useCase) + + await appState.resolveInitialAuthState() + + XCTAssertEqual(appState.rootState, .authenticated) + XCTAssertEqual(useCase.executionCount, 1) + } + + func testResolveInitialAuthStateMapsRequiresLoginResult() async { + let useCase = ResolveInitialAuthStateUseCaseStub(result: .requiresLogin) + let appState = AppState(resolveInitialAuthStateUseCase: useCase) + + await appState.resolveInitialAuthState() + + XCTAssertEqual(appState.rootState, .requiresLogin) + } + + func testResolveInitialAuthStateExecutesOnlyOnce() async { + let useCase = ResolveInitialAuthStateUseCaseStub(result: .requiresLogin) + let appState = AppState(resolveInitialAuthStateUseCase: useCase) + + await appState.resolveInitialAuthState() + await appState.resolveInitialAuthState() + + XCTAssertEqual(useCase.executionCount, 1) + } + + func testLoginAndLogoutUpdateRootState() { + let appState = AppState( + resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCaseStub( + result: .requiresLogin + ) + ) + + appState.didLogin() + XCTAssertEqual(appState.rootState, .authenticated) + + appState.didLogout() + XCTAssertEqual(appState.rootState, .requiresLogin) + } +} + +private final class ResolveInitialAuthStateUseCaseStub: ResolveInitialAuthStateUseCase { + private let result: AuthState + private(set) var executionCount = 0 + + init(result: AuthState) { + self.result = result + } + + func execute() async -> AuthState { + executionCount += 1 + return result + } +} From cf0e6232bf74ac450a66340ba0e54dc10ca960bc Mon Sep 17 00:00:00 2001 From: h9kwon Date: Sun, 12 Jul 2026 16:50:02 +0900 Subject: [PATCH 08/14] refactor: remove user manager and apply usecases --- Siksha.xcodeproj/project.pbxproj | 2 + .../Data/Network/UserRemoteDataSource.swift | 14 +- Siksha/Presentation/Community/AlertView.swift | 5 +- .../Community/CommunityPostView.swift | 10 +- Siksha/Presentation/ContentView.swift | 5 +- .../Settings/AccountManageView.swift | 5 +- .../Settings/ProfileEditView.swift | 18 +- .../Settings/ProfileEditViewModel.swift | 134 ++++++----- .../Settings/RenewalSettingsView.swift | 24 +- .../Settings/RenewalSettingsViewModel.swift | 211 +++++++++--------- .../Settings/RenewalVOCView.swift | 9 +- Siksha/Presentation/UserManager.swift | 89 -------- Siksha/System/AppDelegate.swift | 6 - SikshaTests/ProfileEditViewModelTests.swift | 147 ++++++++++++ .../RenewalSettingsViewModelTests.swift | 165 ++++++++++++++ 15 files changed, 558 insertions(+), 286 deletions(-) delete mode 100644 Siksha/Presentation/UserManager.swift create mode 100644 SikshaTests/ProfileEditViewModelTests.swift create mode 100644 SikshaTests/RenewalSettingsViewModelTests.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index 7868aa5e..5b38fcaa 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -79,6 +79,8 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( AppStateTests.swift, + ProfileEditViewModelTests.swift, + RenewalSettingsViewModelTests.swift, SikshaTests.swift, ); target = 1E3A56FC25C6FCE900DBB3F4 /* SikshaTests */; diff --git a/Siksha/Data/Network/UserRemoteDataSource.swift b/Siksha/Data/Network/UserRemoteDataSource.swift index c235f3f9..279d7c9e 100644 --- a/Siksha/Data/Network/UserRemoteDataSource.swift +++ b/Siksha/Data/Network/UserRemoteDataSource.swift @@ -39,11 +39,15 @@ final class UserRemoteDataSourceImpl: UserRemoteDataSource { changeToDefaultImage: changeToDefaultImage ) - return try await AF - .upload(multipartFormData: endpoint.multipartFormData!, with: endpoint) - .validate() - .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) - .value + do { + return try await AF + .upload(multipartFormData: endpoint.multipartFormData!, with: endpoint) + .validate() + .serializingDecodable(UserDTO.self, decoder: NetworkDecoder.make()) + .value + } catch let error as AFError where error.responseCode == 409 { + throw NetworkError.conflict + } } func submitVOC(comment: String, platform: String) async throws { diff --git a/Siksha/Presentation/Community/AlertView.swift b/Siksha/Presentation/Community/AlertView.swift index 3dc839e6..04037643 100644 --- a/Siksha/Presentation/Community/AlertView.swift +++ b/Siksha/Presentation/Community/AlertView.swift @@ -184,7 +184,10 @@ struct AlertView: View where CommunityPostViewModel: Com #Preview { AlertView( RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ), CommunityPostViewModel(communityRepository: AppContainer.shared.domain.communityRepository, postId: 1) ) diff --git a/Siksha/Presentation/Community/CommunityPostView.swift b/Siksha/Presentation/Community/CommunityPostView.swift index 6c74d10d..8dcb3c13 100644 --- a/Siksha/Presentation/Community/CommunityPostView.swift +++ b/Siksha/Presentation/Community/CommunityPostView.swift @@ -445,7 +445,10 @@ struct CommunityPostView: View where ViewModel: CommunityPostViewMode case .post: AlertView( RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ), viewModel, commentId: nil @@ -453,7 +456,10 @@ struct CommunityPostView: View where ViewModel: CommunityPostViewMode case .comment(let comment): AlertView( RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ), viewModel, commentId: comment.id diff --git a/Siksha/Presentation/ContentView.swift b/Siksha/Presentation/ContentView.swift index 1d9ce2ca..20355e06 100644 --- a/Siksha/Presentation/ContentView.swift +++ b/Siksha/Presentation/ContentView.swift @@ -64,7 +64,10 @@ struct ContentView: View { ) @StateObject private var communityViewModel = CommunityViewModel(communityRepository: AppContainer.shared.domain.communityRepository) @StateObject private var settingsViewModel = RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ) @StateObject private var myLikedMenuViewModel = MyLikedMenuViewModel( fetchMyLikedMenusUseCase: AppContainer.shared.useCases.fetchMyLikedMenusUseCase, diff --git a/Siksha/Presentation/Settings/AccountManageView.swift b/Siksha/Presentation/Settings/AccountManageView.swift index 20e4a5e6..abb34b85 100644 --- a/Siksha/Presentation/Settings/AccountManageView.swift +++ b/Siksha/Presentation/Settings/AccountManageView.swift @@ -136,7 +136,10 @@ struct AccountManageView_Previews: PreviewProvider { Group { AccountManageView( viewModel: RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ) ) .environmentObject(AppState()) diff --git a/Siksha/Presentation/Settings/ProfileEditView.swift b/Siksha/Presentation/Settings/ProfileEditView.swift index b1f03f15..a91d481c 100644 --- a/Siksha/Presentation/Settings/ProfileEditView.swift +++ b/Siksha/Presentation/Settings/ProfileEditView.swift @@ -32,8 +32,8 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp UIApplication.shared.endEditing() viewModel.setPreviousNickname() } - .onAppear { - viewModel.loadInfo() + .task { + await viewModel.loadInfo() } .onChange(of: viewModel.shouldDismiss) { shouldDismiss in if shouldDismiss { @@ -94,6 +94,10 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp .aspectRatio(contentMode: .fill) .clipShape(Circle()) .frame(width: 171, height: 171) + } else if let profileImageURL = viewModel.profileImageURL { + RemoteImage(url: profileImageURL) + .clipShape(Circle()) + .frame(width: 171, height: 171) } else { Image(.Icons.Common.profileImagePlaceholderBig) .resizable() @@ -186,7 +190,9 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp } private func done() { - viewModel.updateUserProfile() + Task { + await viewModel.updateUserProfile() + } } private var backButton: some View { @@ -255,5 +261,9 @@ struct ClearableTextField: View { } #Preview { - ProfileEditView(viewModel: ProfileEditViewModel()) + ProfileEditView(viewModel: ProfileEditViewModel( + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + updateUserProfileUseCase: AppContainer.shared.useCases.updateUserProfileUseCase, + onUserUpdated: { _ in } + )) } diff --git a/Siksha/Presentation/Settings/ProfileEditViewModel.swift b/Siksha/Presentation/Settings/ProfileEditViewModel.swift index ea11f22c..c622bb5f 100644 --- a/Siksha/Presentation/Settings/ProfileEditViewModel.swift +++ b/Siksha/Presentation/Settings/ProfileEditViewModel.swift @@ -5,119 +5,137 @@ // Created by 이지현 on 5/12/24. // -import Foundation import Combine +import Foundation +@MainActor protocol ProfileEditViewModelType: ObservableObject { var error: AppError? { get set } var nickname: String { get set } var profileImageData: Data? { get set } + var profileImageURL: String? { get } var enableDoneButton: Bool { get } var showNicknameExistsToast: Bool { get } var shouldDismiss: Bool { get } - - func loadInfo() + + func loadInfo() async func resetNickname() func setPreviousNickname() - func updateUserProfile() + func updateUserProfile() async func setProfileImage(with imageData: Data?) } +@MainActor final class ProfileEditViewModel: ProfileEditViewModelType { - - private var cancellables = Set() - @Published var error: AppError? @Published var nickname = "" @Published var profileImageData: Data? + @Published private(set) var profileImageURL: String? @Published private(set) var enableDoneButton = false @Published private(set) var showNicknameExistsToast = false @Published private(set) var shouldDismiss = false @Published private var isProfileImageChanged = false - + + private let fetchCurrentUserUseCase: FetchCurrentUserUseCase + private let updateUserProfileUseCase: UpdateUserProfileUseCase + private let onUserUpdated: (User) -> Void + private var cancellables = Set() private var toastWorkItem: DispatchWorkItem? + private var originalNickname: String? private var previousNickname: String? - + private var shouldUseDefaultProfileImage = false + private var hasLoaded = false + private var doneButtonEnabledPublisher: AnyPublisher { - return Publishers.CombineLatest($nickname, $isProfileImageChanged) - .map { nickname, isProfileImageChanged in - return !nickname.isEmpty && (nickname != UserManager.shared.nickname || isProfileImageChanged ) + Publishers.CombineLatest($nickname, $isProfileImageChanged) + .map { [weak self] nickname, isProfileImageChanged in + guard let self else { return false } + return !nickname.isEmpty + && (nickname != self.originalNickname || isProfileImageChanged) } .eraseToAnyPublisher() } - - init() { + + init( + fetchCurrentUserUseCase: FetchCurrentUserUseCase, + updateUserProfileUseCase: UpdateUserProfileUseCase, + onUserUpdated: @escaping (User) -> Void + ) { + self.fetchCurrentUserUseCase = fetchCurrentUserUseCase + self.updateUserProfileUseCase = updateUserProfileUseCase + self.onUserUpdated = onUserUpdated setupBindings() } - - func loadInfo() { - UserManager.shared.loadUserInfo() + + func loadInfo() async { + guard !hasLoaded else { return } + + do { + let user = try await fetchCurrentUserUseCase.execute() + applyInitialUser(user) + hasLoaded = true + } catch { + self.error = ErrorHelper.categorize(error) + } } - + func resetNickname() { guard let previousNickname else { return } nickname = previousNickname } - + func setPreviousNickname() { previousNickname = nickname } - + func setProfileImage(with imageData: Data?) { - self.profileImageData = imageData - self.isProfileImageChanged = true + profileImageData = imageData + profileImageURL = nil + shouldUseDefaultProfileImage = imageData == nil + isProfileImageChanged = true } - - func updateUserProfile() { - UserManager.shared.updateUserProfile(nickname: nickname, image: profileImageData, changeToDefaultImage: profileImageData == nil) {[weak self] success, error in - if success { - self?.shouldDismiss = true - } else { - self?.error = AppError.unknownError("업데이트 실패") - if let networkError = error as? NetworkError { - switch networkError { - case .conflict: - self?.showToast() - default: - break - } - } - } + + func updateUserProfile() async { + do { + let user = try await updateUserProfileUseCase.execute( + nickname: nickname == originalNickname ? nil : nickname, + image: profileImageData, + changeToDefaultImage: shouldUseDefaultProfileImage + ) + onUserUpdated(user) + shouldDismiss = true + } catch NetworkError.conflict { + showToast() + } catch { + self.error = ErrorHelper.categorize(error) } } - + + private func applyInitialUser(_ user: User) { + originalNickname = user.nickname + previousNickname = user.nickname + nickname = user.nickname ?? "" + profileImageURL = user.profileUrl + profileImageData = nil + shouldUseDefaultProfileImage = false + isProfileImageChanged = false + } + private func showToast() { toastWorkItem?.cancel() showNicknameExistsToast = true - + let workItem = DispatchWorkItem { [weak self] in self?.showNicknameExistsToast = false } toastWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 1.8, execute: workItem) } - + private func setupBindings() { doneButtonEnabledPublisher .receive(on: RunLoop.main) .assign(to: \.enableDoneButton, on: self) .store(in: &cancellables) - - UserManager.shared.$nickname - .receive(on: RunLoop.main) - .sink { [weak self] nickname in - self?.nickname = nickname ?? "" - if self?.previousNickname == nil { - self?.previousNickname = nickname - } - } - .store(in: &cancellables) - - UserManager.shared.$imageData - .receive(on: RunLoop.main) - .sink { [weak self] data in - self?.profileImageData = data - } - .store(in: &cancellables) } } diff --git a/Siksha/Presentation/Settings/RenewalSettingsView.swift b/Siksha/Presentation/Settings/RenewalSettingsView.swift index 7ffdb0b6..86b822bc 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsView.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsView.swift @@ -10,7 +10,6 @@ import UIKit struct RenewalSettingsView: View { @Environment(\.viewController) private var viewControllerHolder: UIViewController? - @ObservedObject var userModel = UserManager.shared @ObservedObject var viewModel: RenewalSettingsViewModel @ObservedObject var orderViewModel: RestaurantOrderViewModel @@ -52,16 +51,20 @@ struct RenewalSettingsView: View { .background(Color.backgroundPrimary) .customNavigationBar(title: "icon") .errorAlert(error: $viewModel.error) + .task { + await viewModel.loadIfNeeded() + } } var profileState: some View { - NavigationLink(destination: ProfileEditView(viewModel: ProfileEditViewModel())) { + NavigationLink(destination: ProfileEditView(viewModel: ProfileEditViewModel( + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + updateUserProfileUseCase: AppContainer.shared.useCases.updateUserProfileUseCase, + onUserUpdated: viewModel.applyUpdatedUser + ))) { HStack(spacing: 11) { - if let profileImageData = userModel.imageData, - let uiImage = UIImage(data: profileImageData) { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) + if let profileImageURL = viewModel.user?.profileUrl { + RemoteImage(url: profileImageURL) .clipShape(Circle()) .frame(width: 48, height: 48) } else { @@ -70,7 +73,7 @@ struct RenewalSettingsView: View { .frame(width: 48, height: 48) } - Text(userModel.nickname ?? "무명의 미식가") + Text(viewModel.user?.nickname ?? "무명의 미식가") .customFont(font: .text16(weight: .Bold)) .foregroundColor(gray900) @@ -285,7 +288,10 @@ struct RenewalSettingsView_Previews: PreviewProvider { Group { RenewalSettingsView( viewModel: RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ), orderViewModel: RestaurantOrderViewModel( fetchPersonalRestaurantsUseCase: AppContainer.shared.useCases.fetchPersonalRestaurantsUseCase, diff --git a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift index c840ce60..af280bc9 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift @@ -5,122 +5,105 @@ // Created by 김령교 on 3/3/24. // -import Foundation import Combine -import SwiftyJSON import FirebaseMessaging +import Foundation -class RenewalSettingsViewModel: ObservableObject { - private var cancellables = Set() - - private let repository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository - private let authRepository: LegacyAuthRepositoryProtocol = AppContainer.shared.domain.authRepository - private let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase +@MainActor +final class RenewalSettingsViewModel: ObservableObject { @Published var error: AppError? - @Published var noMenuHide = false - @Published var networkStatus: NetworkStatus = .idle - @Published var showSignOutAlert: Bool = false - @Published var showRemoveAccountAlert: Bool = false - @Published var removeAccountFailed: Bool = false - @Published var logoutFailed: Bool = false - @Published var version: String = "" - @Published var appStoreVersion: String = "" - - @Published var showVOC: Bool = false + @Published var showSignOutAlert = false + @Published var showRemoveAccountAlert = false + @Published var removeAccountFailed = false + @Published var logoutFailed = false + @Published var version: String + @Published var appStoreVersion = "" + @Published var showVOC = false @Published var postVOCStatus: NetworkStatus = .idle - @Published var userId: Int = 0 - @Published var vocComment: String = "" - @Published var alertMessage: String = "" - @Published var showAlert: Bool = false - - - func getVersion() { - guard let dictionary = Bundle.main.infoDictionary, - let version = dictionary["CFBundleShortVersionString"] as? String else { - return - } - self.version = version - } - - func loadInfo() { - UserManager.shared.loadUserInfo() - } - - func getAppStoreVersion() { - guard let url = URL(string: "http://itunes.apple.com/lookup?id=1032700617"), - let data = try? Data(contentsOf: url), - let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], - let results = json["results"] as? [[String: Any]], - results.count > 0, - let appStoreVersion = results[0]["version"] as? String - else { return } - self.appStoreVersion = appStoreVersion - } - + @Published private(set) var user: User? + @Published var userId = 0 + @Published var vocComment = "" + @Published var alertMessage = "" + @Published var showAlert = false + + private let repository: LegacyUserRepositoryProtocol + private let authRepository: LegacyAuthRepositoryProtocol + private let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase + private let fetchCurrentUserUseCase: FetchCurrentUserUseCase + private let submitVOCUseCase: SubmitVOCUseCase + private let fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase + private var cancellables = Set() + private var hasLoadedUser = false + private var hasLoadedAppStoreVersion = false + private var isLoading = false + var isUpdateAvailable: Bool { - if self.version == self.appStoreVersion && self.version != "" { - return false - } else { - return true - } + version != appStoreVersion || version.isEmpty } - - init(manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase) { + + init( + manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: FetchCurrentUserUseCase, + submitVOCUseCase: SubmitVOCUseCase, + fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase, + repository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository, + authRepository: LegacyAuthRepositoryProtocol = AppContainer.shared.domain.authRepository, + version: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + ) { self.manageRestaurantsWithoutMenuVisibilityUseCase = manageRestaurantsWithoutMenuVisibilityUseCase + self.fetchCurrentUserUseCase = fetchCurrentUserUseCase + self.submitVOCUseCase = submitVOCUseCase + self.fetchAppStoreVersionUseCase = fetchAppStoreVersionUseCase + self.repository = repository + self.authRepository = authRepository + self.version = version noMenuHide = manageRestaurantsWithoutMenuVisibilityUseCase.shouldHideRestaurantsWithoutMenu() - - getUserId() - getVersion() - getAppStoreVersion() - loadInfo() - + $noMenuHide + .dropFirst() .sink { [weak self] hide in self?.manageRestaurantsWithoutMenuVisibilityUseCase.setShouldHideRestaurantsWithoutMenu(hide) } .store(in: &cancellables) - - } - - - func getUserId() { - repository.loadUserInfo() - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - self?.error = ErrorHelper.categorize(error) - } - }, receiveValue: { [weak self] user in - self?.userId = user.id - }) - .store(in: &cancellables) + + func loadIfNeeded() async { + guard !isLoading else { return } + guard !hasLoadedUser || !hasLoadedAppStoreVersion else { return } + isLoading = true + defer { isLoading = false } + + if !hasLoadedUser { + hasLoadedUser = await loadUser() + } + if !hasLoadedAppStoreVersion { + hasLoadedAppStoreVersion = await loadAppStoreVersion() + } } - - func sendVOC() { + + func applyUpdatedUser(_ user: User) { + self.user = user + userId = user.id + } + + func sendVOC() async { postVOCStatus = .loading - repository.submitVOC(comment: vocComment, platform: "iOS") - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completionStatus in - switch completionStatus { - case .finished: - self?.postVOCStatus = .succeeded - self?.alertMessage = "전송했습니다." - self?.showAlert = true - case .failure(let error): - self?.postVOCStatus = .failed - self?.error = ErrorHelper.categorize(error) - self?.alertMessage = "전송에 실패했습니다. 다시 시도해주세요." - self?.showAlert = true - } - }, receiveValue: { value in - - }) - .store(in: &cancellables) + + do { + try await submitVOCUseCase.execute(comment: vocComment, platform: "iOS") + postVOCStatus = .succeeded + alertMessage = "전송했습니다." + showAlert = true + } catch { + postVOCStatus = .failed + self.error = ErrorHelper.categorize(error) + alertMessage = "전송에 실패했습니다. 다시 시도해주세요." + showAlert = true + } } - + func logOutAccount(completion:@escaping(Bool)->()) { if UserDefaults.standard.string(forKey: "fcmToken") == nil{ UserDefaults.standard.removeObject(forKey: "accessToken") @@ -141,7 +124,6 @@ class RenewalSettingsViewModel: ObservableObject { self?.error = ErrorHelper.categorize(error) self?.logoutFailed = true completion(false) - } else{ print("delete done") @@ -149,10 +131,7 @@ class RenewalSettingsViewModel: ObservableObject { UserDefaults.standard.removeObject(forKey: "accessToken") UserDefaults.standard.removeObject( forKey: "alreadySentFCM") UserDefaults.standard.set(false,forKey: "isAlarmEnabled") - - completion(true) - } } case .failure(let error): @@ -162,15 +141,13 @@ class RenewalSettingsViewModel: ObservableObject { self?.logoutFailed = true completion(false) } - }, receiveValue: { value in - - }) + }, receiveValue: { _ in }) .store(in: &cancellables) } } - + func removeAccount(completion: @escaping (Bool) -> Void) { - guard let accessToken = UserDefaults.standard.string(forKey: "accessToken") else { + guard UserDefaults.standard.string(forKey: "accessToken") != nil else { removeAccountFailed = true return } @@ -185,9 +162,27 @@ class RenewalSettingsViewModel: ObservableObject { self?.error = ErrorHelper.categorize(error) completion(false) } - }, receiveValue: { value in - - }) + }, receiveValue: { _ in }) .store(in: &cancellables) } + + private func loadUser() async -> Bool { + do { + applyUpdatedUser(try await fetchCurrentUserUseCase.execute()) + return true + } catch { + self.error = ErrorHelper.categorize(error) + return false + } + } + + private func loadAppStoreVersion() async -> Bool { + do { + appStoreVersion = try await fetchAppStoreVersionUseCase.execute().rawValue + return true + } catch { + self.error = ErrorHelper.categorize(error) + return false + } + } } diff --git a/Siksha/Presentation/Settings/RenewalVOCView.swift b/Siksha/Presentation/Settings/RenewalVOCView.swift index cbd54695..a7295528 100644 --- a/Siksha/Presentation/Settings/RenewalVOCView.swift +++ b/Siksha/Presentation/Settings/RenewalVOCView.swift @@ -80,7 +80,9 @@ struct RenewalVOCView: View { Spacer() Button(action: { - viewModel.sendVOC() + Task { + await viewModel.sendVOC() + } }, label: { ZStack { RoundedRectangle(cornerRadius: 8) @@ -126,7 +128,10 @@ struct RenewalVOCView: View { #Preview { RenewalVOCView( RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase + manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, + fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, + submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, + fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase ) ) } diff --git a/Siksha/Presentation/UserManager.swift b/Siksha/Presentation/UserManager.swift deleted file mode 100644 index c9bcda77..00000000 --- a/Siksha/Presentation/UserManager.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// UserManager.swift -// Siksha -// -// Created by Chaehyun Park on 2023/12/04. -// - -import Foundation -import Combine -import Alamofire -import UIKit - -class UserManager: ObservableObject { - static let shared = UserManager() - - @Published var error: AppError? - @Published var nickname: String? - @Published var imageURL: String? { - didSet { - if let imageURL { - fetchImageData(from: imageURL) { [weak self] data in - self?.imageData = data - } - } else { - imageData = nil - } - } - } - @Published var imageData: Data? - - private var cancellables = Set() - private let userRepository: LegacyUserRepositoryProtocol - - private init(userRepository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository) { - self.userRepository = userRepository - } - - func loadUserInfo() { - userRepository.loadUserInfo() - .receive(on: RunLoop.main) - .sink(receiveCompletion: { completion in - if case .failure(let error) = completion { - self.error = ErrorHelper.categorize(error) - } - }, receiveValue: { [weak self] user in - self?.nickname = user.nickname - self?.imageURL = user.profileUrl - }) - .store(in: &cancellables) - } - - func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool, completion: @escaping (Bool, Error?) -> Void) { - let nickname = self.nickname == nickname ? nil : nickname - userRepository.updateUserProfile(nickname: nickname, image: image, changeToDefaultImage: changeToDefaultImage) - .receive(on: DispatchQueue.main) - .sink { completionStatus in - switch completionStatus { - case .finished: - completion(true, nil) - case .failure(let error): - completion(false, error) - } - } receiveValue: { [weak self] user in - self?.nickname = user.nickname - self?.imageURL = user.profileUrl - } - .store(in: &cancellables) - } - - func fetchImage(from urlString: String) -> AnyPublisher { - return Future { promise in - AF.request(urlString).responseData { response in - if let data = response.data, let image = UIImage(data: data) { - promise(.success(image)) - } else { - promise(.success(nil)) - } - } - } - .eraseToAnyPublisher() - } - - private func fetchImageData(from urlString: String, completion: @escaping (Data?) -> Void) { - AF.request(urlString).responseData { response in - completion(response.data) - } - } - -} diff --git a/Siksha/System/AppDelegate.swift b/Siksha/System/AppDelegate.swift index 57d1ed8b..a6512471 100644 --- a/Siksha/System/AppDelegate.swift +++ b/Siksha/System/AppDelegate.swift @@ -64,12 +64,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // 2. Realm이 새로운 Object를 쓸 수 있도록 설정 Realm.Configuration.defaultConfiguration = config - UserManager.shared.loadUserInfo() - - - - - return true } diff --git a/SikshaTests/ProfileEditViewModelTests.swift b/SikshaTests/ProfileEditViewModelTests.swift new file mode 100644 index 00000000..b2d6308c --- /dev/null +++ b/SikshaTests/ProfileEditViewModelTests.swift @@ -0,0 +1,147 @@ +// +// ProfileEditViewModelTests.swift +// SikshaTests +// + +import XCTest +@testable import Siksha + +@MainActor +final class ProfileEditViewModelTests: XCTestCase { + func testLoadPopulatesEditableUserStateOnlyOnce() async { + let fetch = FetchCurrentUserUseCaseStub(user: .fixture()) + let viewModel = makeViewModel(fetch: fetch) + + await viewModel.loadInfo() + await viewModel.loadInfo() + + XCTAssertEqual(viewModel.nickname, "기존 닉네임") + XCTAssertEqual(viewModel.profileImageURL, "https://example.com/profile.jpg") + XCTAssertNil(viewModel.profileImageData) + XCTAssertFalse(viewModel.enableDoneButton) + XCTAssertEqual(fetch.executionCount, 1) + } + + func testUpdatePassesOnlyChangedNicknameAndCallsBackWithUpdatedUser() async { + let updatedUser = User.fixture(nickname: "새 닉네임") + let update = UpdateUserProfileUseCaseStub(result: .success(updatedUser)) + var callbackUser: User? + let viewModel = makeViewModel( + update: update, + onUserUpdated: { callbackUser = $0 } + ) + await viewModel.loadInfo() + viewModel.nickname = "새 닉네임" + + await viewModel.updateUserProfile() + + XCTAssertEqual(update.nickname, "새 닉네임") + XCTAssertNil(update.image) + XCTAssertFalse(update.changeToDefaultImage) + XCTAssertEqual(callbackUser?.nickname, "새 닉네임") + XCTAssertTrue(viewModel.shouldDismiss) + } + + func testSelectingDefaultImageRequestsDefaultWithoutImageData() async { + let update = UpdateUserProfileUseCaseStub(result: .success(.fixture(profileUrl: nil))) + let viewModel = makeViewModel(update: update) + await viewModel.loadInfo() + + viewModel.setProfileImage(with: nil) + await viewModel.updateUserProfile() + + XCTAssertNil(update.nickname) + XCTAssertNil(update.image) + XCTAssertTrue(update.changeToDefaultImage) + } + + func testSelectingNewImagePassesImageWithoutDefaultFlag() async { + let image = Data([0x01, 0x02]) + let update = UpdateUserProfileUseCaseStub(result: .success(.fixture())) + let viewModel = makeViewModel(update: update) + await viewModel.loadInfo() + + viewModel.setProfileImage(with: image) + await viewModel.updateUserProfile() + + XCTAssertEqual(update.image, image) + XCTAssertFalse(update.changeToDefaultImage) + } + + func testConflictShowsNicknameToastWithoutDismissing() async { + let update = UpdateUserProfileUseCaseStub(result: .failure(NetworkError.conflict)) + let viewModel = makeViewModel(update: update) + await viewModel.loadInfo() + viewModel.nickname = "중복 닉네임" + + await viewModel.updateUserProfile() + + XCTAssertTrue(viewModel.showNicknameExistsToast) + XCTAssertFalse(viewModel.shouldDismiss) + } + + private func makeViewModel( + fetch: FetchCurrentUserUseCaseStub = FetchCurrentUserUseCaseStub(user: .fixture()), + update: UpdateUserProfileUseCaseStub = UpdateUserProfileUseCaseStub(result: .success(.fixture())), + onUserUpdated: @escaping (User) -> Void = { _ in } + ) -> ProfileEditViewModel { + ProfileEditViewModel( + fetchCurrentUserUseCase: fetch, + updateUserProfileUseCase: update, + onUserUpdated: onUserUpdated + ) + } +} + +private final class FetchCurrentUserUseCaseStub: FetchCurrentUserUseCase { + let user: User + private(set) var executionCount = 0 + + init(user: User) { + self.user = user + } + + func execute() async throws -> User { + executionCount += 1 + return user + } +} + +private final class UpdateUserProfileUseCaseStub: UpdateUserProfileUseCase { + let result: Result + private(set) var nickname: String? + private(set) var image: Data? + private(set) var changeToDefaultImage = false + + init(result: Result) { + self.result = result + } + + func execute( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + self.nickname = nickname + self.image = image + self.changeToDefaultImage = changeToDefaultImage + return try result.get() + } +} + +private extension User { + static func fixture( + nickname: String? = "기존 닉네임", + profileUrl: String? = "https://example.com/profile.jpg" + ) -> User { + User( + id: 1, + type: "apple", + identity: "identity", + nickname: nickname, + profileUrl: profileUrl, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } +} diff --git a/SikshaTests/RenewalSettingsViewModelTests.swift b/SikshaTests/RenewalSettingsViewModelTests.swift new file mode 100644 index 00000000..ecc70325 --- /dev/null +++ b/SikshaTests/RenewalSettingsViewModelTests.swift @@ -0,0 +1,165 @@ +// +// RenewalSettingsViewModelTests.swift +// SikshaTests +// + +import Combine +import XCTest +@testable import Siksha + +@MainActor +final class RenewalSettingsViewModelTests: XCTestCase { + func testLoadIfNeededFetchesUserAndVersionOnlyOnce() async { + let fetchUser = SettingsFetchCurrentUserUseCaseStub(user: .settingsFixture()) + let fetchVersion = FetchAppStoreVersionUseCaseStub(version: AppVersion(rawValue: "2.0.0")) + let viewModel = makeViewModel(fetchUser: fetchUser, fetchVersion: fetchVersion) + + await viewModel.loadIfNeeded() + await viewModel.loadIfNeeded() + + XCTAssertEqual(viewModel.user?.nickname, "설정 닉네임") + XCTAssertEqual(viewModel.userId, 10) + XCTAssertEqual(viewModel.appStoreVersion, "2.0.0") + XCTAssertEqual(fetchUser.executionCount, 1) + XCTAssertEqual(fetchVersion.executionCount, 1) + } + + func testApplyUpdatedUserRefreshesSettingsState() { + let viewModel = makeViewModel() + + viewModel.applyUpdatedUser(.settingsFixture(id: 20, nickname: "수정 닉네임")) + + XCTAssertEqual(viewModel.user?.nickname, "수정 닉네임") + XCTAssertEqual(viewModel.userId, 20) + } + + func testLoadIfNeededRetriesOnlyFailedRequest() async { + let fetchUser = SettingsFetchCurrentUserUseCaseStub(user: .settingsFixture()) + let fetchVersion = FetchAppStoreVersionUseCaseStub( + results: [.failure(TestError.expected), .success(AppVersion(rawValue: "2.0.0"))] + ) + let viewModel = makeViewModel(fetchUser: fetchUser, fetchVersion: fetchVersion) + + await viewModel.loadIfNeeded() + await viewModel.loadIfNeeded() + + XCTAssertEqual(fetchUser.executionCount, 1) + XCTAssertEqual(fetchVersion.executionCount, 2) + XCTAssertEqual(viewModel.appStoreVersion, "2.0.0") + } + + func testSendVOCTransitionsToSucceeded() async { + let submitVOC = SubmitVOCUseCaseStub(result: .success(())) + let viewModel = makeViewModel(submitVOC: submitVOC) + viewModel.vocComment = "문의 내용" + + await viewModel.sendVOC() + + guard case .succeeded = viewModel.postVOCStatus else { + return XCTFail("Expected succeeded status") + } + XCTAssertEqual(submitVOC.comment, "문의 내용") + XCTAssertEqual(submitVOC.platform, "iOS") + XCTAssertEqual(viewModel.alertMessage, "전송했습니다.") + XCTAssertTrue(viewModel.showAlert) + } + + private func makeViewModel( + fetchUser: SettingsFetchCurrentUserUseCaseStub = SettingsFetchCurrentUserUseCaseStub(user: .settingsFixture()), + submitVOC: SubmitVOCUseCaseStub = SubmitVOCUseCaseStub(result: .success(())), + fetchVersion: FetchAppStoreVersionUseCaseStub = FetchAppStoreVersionUseCaseStub(version: AppVersion(rawValue: "1.0.0")) + ) -> RenewalSettingsViewModel { + RenewalSettingsViewModel( + manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCaseStub(), + fetchCurrentUserUseCase: fetchUser, + submitVOCUseCase: submitVOC, + fetchAppStoreVersionUseCase: fetchVersion, + repository: LegacyUserRepositoryStub(), + authRepository: LegacyAuthRepositoryStub(), + version: "1.0.0" + ) + } +} + +private final class SettingsFetchCurrentUserUseCaseStub: FetchCurrentUserUseCase { + let user: User + private(set) var executionCount = 0 + + init(user: User) { + self.user = user + } + + func execute() async throws -> User { + executionCount += 1 + return user + } +} + +private final class SubmitVOCUseCaseStub: SubmitVOCUseCase { + let result: Result + private(set) var comment: String? + private(set) var platform: String? + + init(result: Result) { + self.result = result + } + + func execute(comment: String, platform: String) async throws { + self.comment = comment + self.platform = platform + try result.get() + } +} + +private final class FetchAppStoreVersionUseCaseStub: FetchAppStoreVersionUseCase { + private var results: [Result] + private(set) var executionCount = 0 + + init(version: AppVersion) { + self.results = [.success(version)] + } + + init(results: [Result]) { + self.results = results + } + + func execute() async throws -> AppVersion { + executionCount += 1 + return try results.removeFirst().get() + } +} + +private enum TestError: Error { + case expected +} + +private final class ManageRestaurantsWithoutMenuVisibilityUseCaseStub: ManageRestaurantsWithoutMenuVisibilityUseCase { + func shouldHideRestaurantsWithoutMenu() -> Bool { false } + func setShouldHideRestaurantsWithoutMenu(_ shouldHide: Bool) {} +} + +private final class LegacyUserRepositoryStub: LegacyUserRepositoryProtocol { + func loadUserInfo() -> AnyPublisher { Empty().eraseToAnyPublisher() } + func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher { Empty().eraseToAnyPublisher() } + func submitVOC(comment: String, platform: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } + func deleteUser() -> AnyPublisher { Empty().eraseToAnyPublisher() } +} + +private final class LegacyAuthRepositoryStub: LegacyAuthRepositoryProtocol { + func postUserDevice(fcmToken: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } + func deleteUserDevice(fcmToken: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } +} + +private extension User { + static func settingsFixture(id: Int = 10, nickname: String? = "설정 닉네임") -> User { + User( + id: id, + type: "apple", + identity: "identity", + nickname: nickname, + profileUrl: "https://example.com/profile.jpg", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } +} From abe96f81a23ffe381bec4da3039b8743eb95f8e9 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Mon, 13 Jul 2026 21:53:03 +0900 Subject: [PATCH 09/14] fix(image): use injected cache and cancel stale image requests --- Siksha.xcodeproj/project.pbxproj | 1 + Siksha/Library/Extensions/Extensions.swift | 37 ++-- .../ImagePickerCoordinatorView.swift | 91 +++++++-- .../Library/View Elements/RemoteImage.swift | 151 +++++++++------ .../Settings/ProfileEditView.swift | 12 +- .../Settings/ProfileEditViewModel.swift | 31 +++- SikshaTests/ProfileEditViewModelTests.swift | 73 +++++++- SikshaTests/RemoteImageLoaderTests.swift | 173 ++++++++++++++++++ 8 files changed, 470 insertions(+), 99 deletions(-) create mode 100644 SikshaTests/RemoteImageLoaderTests.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index 5b38fcaa..f4a3f7f2 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ membershipExceptions = ( AppStateTests.swift, ProfileEditViewModelTests.swift, + RemoteImageLoaderTests.swift, RenewalSettingsViewModelTests.swift, SikshaTests.swift, ); diff --git a/Siksha/Library/Extensions/Extensions.swift b/Siksha/Library/Extensions/Extensions.swift index d84b65e6..875432cc 100644 --- a/Siksha/Library/Extensions/Extensions.swift +++ b/Siksha/Library/Extensions/Extensions.swift @@ -117,11 +117,11 @@ extension URLRequest { } } -protocol ImageCache { +protocol ImageCache: AnyObject { subscript(_ url: URL) -> UIImage? { get set } } -struct TemporaryImageCache: ImageCache { +final class TemporaryImageCache: ImageCache { private let cache = NSCache() subscript(_ key: URL) -> UIImage? { @@ -208,15 +208,28 @@ extension Date { } extension UIImage { - func resizeWithWidth(width: CGFloat) -> UIImage? { - let imageView = UIImageView(frame: CGRect(origin: .zero, size: CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height))))) - imageView.contentMode = .scaleAspectFit - imageView.image = self - UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, false, scale) - guard let context = UIGraphicsGetCurrentContext() else { return nil } - imageView.layer.render(in: context) - guard let result = UIGraphicsGetImageFromCurrentImageContext() else { return nil } - UIGraphicsEndImageContext() - return result + func resizedToFit(maxPixelDimension: CGFloat) -> UIImage? { + guard maxPixelDimension > 0, size.width > 0, size.height > 0 else { + return nil + } + + let pixelSize = CGSize(width: size.width * scale, height: size.height * scale) + let longestDimension = max(pixelSize.width, pixelSize.height) + guard longestDimension > maxPixelDimension else { + return self + } + + let ratio = maxPixelDimension / longestDimension + let targetSize = CGSize( + width: max(1, floor(pixelSize.width * ratio)), + height: max(1, floor(pixelSize.height * ratio)) + ) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = false + + return UIGraphicsImageRenderer(size: targetSize, format: format).image { _ in + draw(in: CGRect(origin: .zero, size: targetSize)) + } } } diff --git a/Siksha/Library/View Elements/ImagePickerCoordinatorView.swift b/Siksha/Library/View Elements/ImagePickerCoordinatorView.swift index 634c2722..cce7d5b5 100644 --- a/Siksha/Library/View Elements/ImagePickerCoordinatorView.swift +++ b/Siksha/Library/View Elements/ImagePickerCoordinatorView.swift @@ -9,11 +9,26 @@ import SwiftUI import Photos import BSImagePicker +enum ImagePickerError: LocalizedError { + case imageUnavailable + case imageProcessingFailed + + var errorDescription: String? { + switch self { + case .imageUnavailable: + return "선택한 사진을 불러올 수 없습니다. 네트워크 연결을 확인한 후 다시 시도해 주세요." + case .imageProcessingFailed: + return "선택한 사진을 처리할 수 없습니다. 다른 사진을 선택해 주세요." + } + } +} + struct ImagePickerCoordinatorView { @Environment(\.presentationMode) var presentationMode: Binding @Binding var selectedImages: [UIImage] var maxSelection: Int var onImagesSelected: (([UIImage]) -> Void)? + var onError: ((Error) -> Void)? private func dismiss() { self.presentationMode.wrappedValue.dismiss() @@ -66,10 +81,21 @@ extension ImagePickerCoordinatorView { public func imagePicker(_ imagePicker: ImagePickerController, didFinishWithAssets assets: [PHAsset]) { print("Finished with selections: \(assets)") - - let newImages = getAssetThumbnail(assets: assets) - parent.selectedImages.append(contentsOf: newImages) - parent.onImagesSelected?(newImages) + + Task { @MainActor in + do { + let result = try await loadImages(from: assets) + if !result.images.isEmpty { + parent.selectedImages.append(contentsOf: result.images) + parent.onImagesSelected?(result.images) + } + if let error = result.error { + parent.onError?(error) + } + } catch is CancellationError { + return + } + } } public func imagePicker(_ imagePicker: ImagePickerController, didCancelWithAssets assets: [PHAsset]) { @@ -80,22 +106,53 @@ extension ImagePickerCoordinatorView { print("Did Reach Selection Limit: \(count)") } - func getAssetThumbnail(assets: [PHAsset]) -> [UIImage] { - var images = [UIImage]() + private func loadImages(from assets: [PHAsset]) async throws -> (images: [UIImage], error: Error?) { + var images: [UIImage] = [] + var firstError: Error? + images.reserveCapacity(assets.count) + for asset in assets { - let manager = PHImageManager.default() - let option = PHImageRequestOptions() - var image: UIImage? - option.isSynchronous = true - manager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .aspectFit, options: option, resultHandler: {(result, info)->Void in - image = result - }) - image = image?.resizeWithWidth(width: 800) - if let image { - images.append(image) + try Task.checkCancellation() + do { + let image = try await requestImage(for: asset) + guard let resizedImage = image.resizedToFit(maxPixelDimension: 800) else { + throw ImagePickerError.imageProcessingFailed + } + images.append(resizedImage) + } catch is CancellationError { + throw CancellationError() + } catch { + firstError = firstError ?? error + } + } + + return (images, firstError) + } + + private func requestImage(for asset: PHAsset) async throws -> UIImage { + try await withCheckedThrowingContinuation { continuation in + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.resizeMode = .exact + options.isNetworkAccessAllowed = true + + PHImageManager.default().requestImage( + for: asset, + targetSize: CGSize(width: 1_600, height: 1_600), + contentMode: .aspectFit, + options: options + ) { image, info in + if (info?[PHImageCancelledKey] as? Bool) == true { + continuation.resume(throwing: CancellationError()) + } else if let error = info?[PHImageErrorKey] as? Error { + continuation.resume(throwing: error) + } else if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: ImagePickerError.imageUnavailable) + } } } - return images } } } diff --git a/Siksha/Library/View Elements/RemoteImage.swift b/Siksha/Library/View Elements/RemoteImage.swift index 4d644aa2..1411fc4d 100644 --- a/Siksha/Library/View Elements/RemoteImage.swift +++ b/Siksha/Library/View Elements/RemoteImage.swift @@ -6,81 +6,116 @@ // import SwiftUI -import Combine -struct RemoteImage: View { - private class ImageLoader: ObservableObject { - private static let imageProcessingQueue = DispatchQueue.global(qos: .userInteractive) - - @Published var image: UIImage? - private let url: URL? - private var cache: ImageCache? - private var cancellable: AnyCancellable? - - private var isLoading = false - - init(url: String, cache: ImageCache? = nil) { - self.url = URL(string: url) - self.cache = cache +protocol RemoteImageDataLoading { + func data(from url: URL) async throws -> (Data, URLResponse) +} + +extension URLSession: RemoteImageDataLoading {} + +@MainActor +final class RemoteImageLoader: ObservableObject { + enum Phase { + case idle + case loading + case success(UIImage) + case failed + } + + @Published private(set) var phase: Phase = .idle + + private let dataLoader: RemoteImageDataLoading + private var representedURL: URL? + + init(dataLoader: RemoteImageDataLoading = URLSession.shared) { + self.dataLoader = dataLoader + } + + func load(url urlString: String, cache: ImageCache) async { + guard let url = URL(string: urlString) else { + representedURL = nil + phase = .failed + return } - - deinit { - cancel() + + representedURL = url + + if let cachedImage = cache[url] { + phase = .success(cachedImage) + return } - - func load() { - guard !isLoading else { return } - guard let url = url else { return } - - if let image = cache?[url] { - self.image = image + + phase = .loading + + do { + let (data, response) = try await dataLoader.data(from: url) + try Task.checkCancellation() + guard representedURL == url else { return } + + if let response = response as? HTTPURLResponse, + !(200..<300).contains(response.statusCode) { + phase = .failed return } - - cancellable = URLSession.shared.dataTaskPublisher(for: url) - .subscribe(on: Self.imageProcessingQueue) - .map { UIImage(data: $0.data) } - .replaceError(with: nil) - .handleEvents(receiveOutput: { [weak self] in self?.cache($0) }) - .receive(on: RunLoop.main) - .assign(to: \.image, on: self) - } - - func cancel() { - cancellable?.cancel() - } - - private func cache(_ image: UIImage?) { - guard let url = url else { return } - image.map { cache?[url] = $0 } + + guard let image = UIImage(data: data) else { + phase = .failed + return + } + + cache[url] = image + phase = .success(image) + } catch is CancellationError { + return + } catch { + guard representedURL == url else { return } + phase = .failed } } - - @ObservedObject private var imageLoader: ImageLoader - +} + +struct RemoteImage: View { + private struct RequestID: Hashable { + let url: String + let cacheIdentifier: ObjectIdentifier + } + + @Environment(\.imageCache) private var imageCache + @StateObject private var imageLoader = RemoteImageLoader() + + private let url: String + init(url: String) { - _imageLoader = ObservedObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue)) + self.url = url } - + var body: some View { Group { - if let image = imageLoader.image { + switch imageLoader.phase { + case .idle, .loading: + ActivityIndicator(isAnimating: .constant(true), style: .medium) + case .success(let image): Image(uiImage: image) .resizable() .renderingMode(.original) .scaledToFill() - } else { - ActivityIndicator(isAnimating: .constant(true), style: .medium) + case .failed: + Image(systemName: "photo") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + .padding() } } - .onAppear { - imageLoader.load() + .task(id: requestID) { + await imageLoader.load(url: url, cache: imageCache) } } -} -//struct RemoteImage_Previews: PreviewProvider { -// static var previews: some View { -// RemoteImage() -// } -//} + private var requestID: RequestID { + RequestID( + url: url, + cacheIdentifier: ObjectIdentifier(imageCache) + ) + } +} diff --git a/Siksha/Presentation/Settings/ProfileEditView.swift b/Siksha/Presentation/Settings/ProfileEditView.swift index a91d481c..4ac72559 100644 --- a/Siksha/Presentation/Settings/ProfileEditView.swift +++ b/Siksha/Presentation/Settings/ProfileEditView.swift @@ -14,12 +14,16 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp @StateObject private var keyboardResponder = KeyboardResponder() @State private var isShowingActionSheet = false @State private var isShowingPhotoLibrary = false + @State private var selectedProfileImages: [UIImage] = [] var body: some View { GeometryReader { _ in ZStack { mainContent keyboardToolbarContent + if viewModel.isLoading { + ProgressView() + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.backgroundPrimary) @@ -58,6 +62,7 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp doneButton .padding(.bottom, 20) } + .disabled(viewModel.isLoading) } private var keyboardToolbarContent: some View { @@ -112,6 +117,7 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp .actionSheet(isPresented: $isShowingActionSheet) { ActionSheet(title: Text("프로필 사진 설정"), buttons: [ .default(Text("앨범에서 사진 선택"), action: { + selectedProfileImages.removeAll() isShowingPhotoLibrary = true }), .default(Text("기본 이미지 적용"), action: { @@ -121,11 +127,15 @@ struct ProfileEditView: View where ViewModel: ProfileEditViewModelTyp ]) } .sheet(isPresented: $isShowingPhotoLibrary) { - ImagePickerCoordinatorView(selectedImages: .constant([]), maxSelection: 1) { images in + ImagePickerCoordinatorView(selectedImages: $selectedProfileImages, maxSelection: 1) { images in if let firstImage = images.first, let imageData = firstImage.jpegData(compressionQuality: 0.8) { viewModel.setProfileImage(with: imageData) + } else { + viewModel.error = .unknownError(ImagePickerError.imageProcessingFailed.localizedDescription) } + } onError: { error in + viewModel.error = .unknownError(error.localizedDescription) } } diff --git a/Siksha/Presentation/Settings/ProfileEditViewModel.swift b/Siksha/Presentation/Settings/ProfileEditViewModel.swift index c622bb5f..c3cfb89c 100644 --- a/Siksha/Presentation/Settings/ProfileEditViewModel.swift +++ b/Siksha/Presentation/Settings/ProfileEditViewModel.swift @@ -15,6 +15,7 @@ protocol ProfileEditViewModelType: ObservableObject { var profileImageData: Data? { get set } var profileImageURL: String? { get } var enableDoneButton: Bool { get } + var isLoading: Bool { get } var showNicknameExistsToast: Bool { get } var shouldDismiss: Bool { get } @@ -32,6 +33,7 @@ final class ProfileEditViewModel: ProfileEditViewModelType { @Published var profileImageData: Data? @Published private(set) var profileImageURL: String? @Published private(set) var enableDoneButton = false + @Published private(set) var isLoading = true @Published private(set) var showNicknameExistsToast = false @Published private(set) var shouldDismiss = false @Published private var isProfileImageChanged = false @@ -44,13 +46,15 @@ final class ProfileEditViewModel: ProfileEditViewModelType { private var originalNickname: String? private var previousNickname: String? private var shouldUseDefaultProfileImage = false - private var hasLoaded = false + @Published private var hasLoaded = false + private var isLoadInProgress = false private var doneButtonEnabledPublisher: AnyPublisher { - Publishers.CombineLatest($nickname, $isProfileImageChanged) - .map { [weak self] nickname, isProfileImageChanged in + Publishers.CombineLatest3($nickname, $isProfileImageChanged, $hasLoaded) + .map { [weak self] nickname, isProfileImageChanged, hasLoaded in guard let self else { return false } - return !nickname.isEmpty + return hasLoaded + && !nickname.isEmpty && (nickname != self.originalNickname || isProfileImageChanged) } .eraseToAnyPublisher() @@ -68,12 +72,20 @@ final class ProfileEditViewModel: ProfileEditViewModelType { } func loadInfo() async { - guard !hasLoaded else { return } + guard !hasLoaded, !isLoadInProgress else { return } + isLoadInProgress = true + isLoading = true + defer { + isLoadInProgress = false + isLoading = false + } do { let user = try await fetchCurrentUserUseCase.execute() applyInitialUser(user) hasLoaded = true + } catch is CancellationError { + return } catch { self.error = ErrorHelper.categorize(error) } @@ -115,10 +127,11 @@ final class ProfileEditViewModel: ProfileEditViewModelType { originalNickname = user.nickname previousNickname = user.nickname nickname = user.nickname ?? "" - profileImageURL = user.profileUrl - profileImageData = nil - shouldUseDefaultProfileImage = false - isProfileImageChanged = false + if !isProfileImageChanged { + profileImageURL = user.profileUrl + profileImageData = nil + shouldUseDefaultProfileImage = false + } } private func showToast() { diff --git a/SikshaTests/ProfileEditViewModelTests.swift b/SikshaTests/ProfileEditViewModelTests.swift index b2d6308c..7c4cab9a 100644 --- a/SikshaTests/ProfileEditViewModelTests.swift +++ b/SikshaTests/ProfileEditViewModelTests.swift @@ -4,6 +4,7 @@ // import XCTest +import UIKit @testable import Siksha @MainActor @@ -22,6 +23,51 @@ final class ProfileEditViewModelTests: XCTestCase { XCTAssertEqual(fetch.executionCount, 1) } + func testImageSelectedWhileInitialLoadIsPendingIsNotOverwritten() async { + let fetchStarted = expectation(description: "fetch started") + let fetch = DelayedFetchCurrentUserUseCaseStub( + user: .fixture(), + onStart: { fetchStarted.fulfill() } + ) + let viewModel = makeViewModel(fetch: fetch) + let selectedImage = Data([0x01, 0x02, 0x03]) + + let loadTask = Task { await viewModel.loadInfo() } + await fulfillment(of: [fetchStarted], timeout: 1) + XCTAssertTrue(viewModel.isLoading) + XCTAssertFalse(viewModel.enableDoneButton) + + viewModel.setProfileImage(with: selectedImage) + fetch.complete() + await loadTask.value + + XCTAssertEqual(viewModel.profileImageData, selectedImage) + XCTAssertNil(viewModel.profileImageURL) + XCTAssertFalse(viewModel.isLoading) + XCTAssertTrue(viewModel.enableDoneButton) + } + + func testResizeLimitsLongestPixelDimensionAndPreservesAspectRatio() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 4_000, height: 1_000)).image { context in + UIColor.orange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4_000, height: 1_000)) + } + + let resized = try XCTUnwrap(source.resizedToFit(maxPixelDimension: 800)) + + XCTAssertEqual(resized.scale, 1) + XCTAssertEqual(resized.size.width, 800) + XCTAssertEqual(resized.size.height, 200) + } + + func testResizeDoesNotUpscaleSmallImage() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 200, height: 100)).image { _ in } + + let resized = try XCTUnwrap(source.resizedToFit(maxPixelDimension: 800)) + + XCTAssertTrue(resized === source) + } + func testUpdatePassesOnlyChangedNicknameAndCallsBackWithUpdatedUser() async { let updatedUser = User.fixture(nickname: "새 닉네임") let update = UpdateUserProfileUseCaseStub(result: .success(updatedUser)) @@ -81,8 +127,8 @@ final class ProfileEditViewModelTests: XCTestCase { } private func makeViewModel( - fetch: FetchCurrentUserUseCaseStub = FetchCurrentUserUseCaseStub(user: .fixture()), - update: UpdateUserProfileUseCaseStub = UpdateUserProfileUseCaseStub(result: .success(.fixture())), + fetch: FetchCurrentUserUseCase = FetchCurrentUserUseCaseStub(user: .fixture()), + update: UpdateUserProfileUseCase = UpdateUserProfileUseCaseStub(result: .success(.fixture())), onUserUpdated: @escaping (User) -> Void = { _ in } ) -> ProfileEditViewModel { ProfileEditViewModel( @@ -93,6 +139,29 @@ final class ProfileEditViewModelTests: XCTestCase { } } +private final class DelayedFetchCurrentUserUseCaseStub: FetchCurrentUserUseCase { + let user: User + let onStart: () -> Void + private var continuation: CheckedContinuation? + + init(user: User, onStart: @escaping () -> Void) { + self.user = user + self.onStart = onStart + } + + func execute() async throws -> User { + onStart() + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func complete() { + continuation?.resume(returning: user) + continuation = nil + } +} + private final class FetchCurrentUserUseCaseStub: FetchCurrentUserUseCase { let user: User private(set) var executionCount = 0 diff --git a/SikshaTests/RemoteImageLoaderTests.swift b/SikshaTests/RemoteImageLoaderTests.swift new file mode 100644 index 00000000..d4c1746e --- /dev/null +++ b/SikshaTests/RemoteImageLoaderTests.swift @@ -0,0 +1,173 @@ +// +// RemoteImageLoaderTests.swift +// SikshaTests +// + +import UIKit +import XCTest +@testable import Siksha + +@MainActor +final class RemoteImageLoaderTests: XCTestCase { + func testCacheHitSkipsNetworkRequest() async throws { + let url = try XCTUnwrap(URL(string: "https://example.com/cached.png")) + let cachedImage = makeImage(color: .orange) + let cache = ImageCacheStub() + cache[url] = cachedImage + let dataLoader = RemoteImageDataLoaderStub(result: .failure(TestError.unexpectedRequest)) + let loader = RemoteImageLoader(dataLoader: dataLoader) + + await loader.load(url: url.absoluteString, cache: cache) + + let requestCount = await dataLoader.requestCount + XCTAssertEqual(requestCount, 0) + assertSuccess(loader.phase, expectedImage: cachedImage) + } + + func testDownloadedImageIsStoredInCache() async throws { + let url = try XCTUnwrap(URL(string: "https://example.com/downloaded.png")) + let downloadedImage = makeImage(color: .blue) + let imageData = try XCTUnwrap(downloadedImage.pngData()) + let response = try XCTUnwrap(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )) + let cache = ImageCacheStub() + let dataLoader = RemoteImageDataLoaderStub(result: .success((imageData, response))) + let loader = RemoteImageLoader(dataLoader: dataLoader) + + await loader.load(url: url.absoluteString, cache: cache) + + let requestCount = await dataLoader.requestCount + XCTAssertEqual(requestCount, 1) + XCTAssertNotNil(cache[url]) + assertSuccess(loader.phase) + } + + func testInvalidImageDataProducesFailedPhase() async throws { + let url = try XCTUnwrap(URL(string: "https://example.com/invalid.png")) + let response = try XCTUnwrap(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )) + let dataLoader = RemoteImageDataLoaderStub(result: .success((Data([0x00]), response))) + let loader = RemoteImageLoader(dataLoader: dataLoader) + + await loader.load(url: url.absoluteString, cache: ImageCacheStub()) + + guard case .failed = loader.phase else { + return XCTFail("Expected failed phase") + } + } + + func testCancelledOldRequestCannotOverwriteLatestImage() async throws { + let firstURL = try XCTUnwrap(URL(string: "https://example.com/first.png")) + let secondURL = try XCTUnwrap(URL(string: "https://example.com/second.png")) + let firstImageData = try XCTUnwrap(makeImage(color: .red).pngData()) + let secondImage = makeImage(color: .green) + let secondImageData = try XCTUnwrap(secondImage.pngData()) + let dataLoader = ControlledRemoteImageDataLoader() + let loader = RemoteImageLoader(dataLoader: dataLoader) + let cache = ImageCacheStub() + + let firstTask = Task { + await loader.load(url: firstURL.absoluteString, cache: cache) + } + await dataLoader.waitUntilRequested(firstURL) + + firstTask.cancel() + let secondTask = Task { + await loader.load(url: secondURL.absoluteString, cache: cache) + } + await dataLoader.waitUntilRequested(secondURL) + + await dataLoader.succeed(url: firstURL, data: firstImageData) + await firstTask.value + await dataLoader.succeed(url: secondURL, data: secondImageData) + await secondTask.value + + XCTAssertNil(cache[firstURL]) + let cachedSecondImage = try XCTUnwrap(cache[secondURL]) + assertSuccess(loader.phase, expectedImage: cachedSecondImage) + } + + private func assertSuccess( + _ phase: RemoteImageLoader.Phase, + expectedImage: UIImage? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .success(let image) = phase else { + return XCTFail("Expected success phase", file: file, line: line) + } + if let expectedImage { + XCTAssertTrue(image === expectedImage, file: file, line: line) + } + } + + private func makeImage(color: UIColor) -> UIImage { + UIGraphicsImageRenderer(size: CGSize(width: 10, height: 10)).image { context in + color.setFill() + context.fill(CGRect(x: 0, y: 0, width: 10, height: 10)) + } + } +} + +private enum TestError: Error { + case unexpectedRequest +} + +private final class ImageCacheStub: ImageCache { + private var images: [URL: UIImage] = [:] + + subscript(_ url: URL) -> UIImage? { + get { images[url] } + set { images[url] = newValue } + } +} + +private actor RemoteImageDataLoaderStub: RemoteImageDataLoading { + private let result: Result<(Data, URLResponse), Error> + private(set) var requestCount = 0 + + init(result: Result<(Data, URLResponse), Error>) { + self.result = result + } + + func data(from url: URL) async throws -> (Data, URLResponse) { + requestCount += 1 + return try result.get() + } +} + +private actor ControlledRemoteImageDataLoader: RemoteImageDataLoading { + private var requestedURLs: Set = [] + private var continuations: [URL: CheckedContinuation<(Data, URLResponse), Error>] = [:] + + func data(from url: URL) async throws -> (Data, URLResponse) { + requestedURLs.insert(url) + return try await withCheckedThrowingContinuation { continuation in + continuations[url] = continuation + } + } + + func waitUntilRequested(_ url: URL) async { + while !requestedURLs.contains(url) { + await Task.yield() + } + } + + func succeed(url: URL, data: Data) { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + continuations.removeValue(forKey: url)?.resume(returning: (data, response)) + } +} From af795b38d4229c3ae60666c7cdf0f1a282a7e301 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Tue, 14 Jul 2026 23:39:16 +0900 Subject: [PATCH 10/14] refactor(auth): centralize account and device token lifecycle --- Siksha.xcodeproj/project.pbxproj | 5 + .../External/FirebaseMessagingService.swift | 49 ++ .../Local/DeviceTokenLocalDataSource.swift | 53 ++ .../Local/RestaurantLocalDataSource.swift | 5 + .../Network/DeviceTokenRemoteDataSource.swift | 46 ++ .../DeviceTokenRepositoryImpl.swift | 47 ++ .../MyLikedMenuRepositoryImpl.swift | 2 +- .../Repository/RestaurantRepositoryImpl.swift | 6 +- ...enuAlarmPreferenceRepositoryProtocol.swift | 10 + ...nalRestaurantStateRepositoryProtocol.swift | 10 + .../PushMessagingTokenServiceProtocol.swift | 14 + .../Auth/AccountLocalStateCleaner.swift | 40 ++ .../UseCase/Auth/DeleteAccountUseCase.swift | 45 ++ .../Domain/UseCase/Auth/LogoutUseCase.swift | 29 + .../DeviceToken/DeviceTokenLifecycle.swift | 130 +++++ .../DeviceTokenOperationGate.swift | 85 +++ .../RegisterUserDeviceUseCase.swift | 22 + .../Settings/AccountManageView.swift | 59 +- .../Settings/AccountManageViewModel.swift | 72 +++ .../Settings/RenewalSettingsView.swift | 7 +- .../Settings/RenewalSettingsViewModel.swift | 73 --- Siksha/System/DI/AppContainer.swift | 11 +- .../DI/MenuAlarmNotificationManaging.swift | 7 + Siksha/System/DI/UseCaseProvider.swift | 43 +- .../System/MenuAlarmNotificationManager.swift | 83 +-- SikshaTests/AccountManageViewModelTests.swift | 149 +++++ SikshaTests/AccountUseCaseTests.swift | 546 ++++++++++++++++++ .../DeviceTokenRemoteDataSourceTests.swift | 76 +++ SikshaTests/DeviceTokenUseCaseTests.swift | 224 +++++++ .../RenewalSettingsViewModelTests.swift | 15 - .../RestaurantLocalDataSourceTests.swift | 60 ++ 31 files changed, 1841 insertions(+), 182 deletions(-) create mode 100644 Siksha/Data/External/FirebaseMessagingService.swift create mode 100644 Siksha/Data/Local/DeviceTokenLocalDataSource.swift create mode 100644 Siksha/Data/Network/DeviceTokenRemoteDataSource.swift create mode 100644 Siksha/Data/Repository/DeviceTokenRepositoryImpl.swift create mode 100644 Siksha/Domain/Repository/MenuAlarmPreferenceRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/PersonalRestaurantStateRepositoryProtocol.swift create mode 100644 Siksha/Domain/Repository/PushMessagingTokenServiceProtocol.swift create mode 100644 Siksha/Domain/UseCase/Auth/AccountLocalStateCleaner.swift create mode 100644 Siksha/Domain/UseCase/Auth/DeleteAccountUseCase.swift create mode 100644 Siksha/Domain/UseCase/Auth/LogoutUseCase.swift create mode 100644 Siksha/Domain/UseCase/DeviceToken/DeviceTokenLifecycle.swift create mode 100644 Siksha/Domain/UseCase/DeviceToken/DeviceTokenOperationGate.swift create mode 100644 Siksha/Domain/UseCase/DeviceToken/RegisterUserDeviceUseCase.swift create mode 100644 Siksha/Presentation/Settings/AccountManageViewModel.swift create mode 100644 SikshaTests/AccountManageViewModelTests.swift create mode 100644 SikshaTests/AccountUseCaseTests.swift create mode 100644 SikshaTests/DeviceTokenRemoteDataSourceTests.swift create mode 100644 SikshaTests/DeviceTokenUseCaseTests.swift create mode 100644 SikshaTests/RestaurantLocalDataSourceTests.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index f4a3f7f2..f8cd25de 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -78,9 +78,14 @@ 3333F90C2F13F2E6004B13CA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + AccountManageViewModelTests.swift, + AccountUseCaseTests.swift, AppStateTests.swift, + DeviceTokenRemoteDataSourceTests.swift, + DeviceTokenUseCaseTests.swift, ProfileEditViewModelTests.swift, RemoteImageLoaderTests.swift, + RestaurantLocalDataSourceTests.swift, RenewalSettingsViewModelTests.swift, SikshaTests.swift, ); diff --git a/Siksha/Data/External/FirebaseMessagingService.swift b/Siksha/Data/External/FirebaseMessagingService.swift new file mode 100644 index 00000000..f0a80e6a --- /dev/null +++ b/Siksha/Data/External/FirebaseMessagingService.swift @@ -0,0 +1,49 @@ +// +// FirebaseMessagingService.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import FirebaseMessaging +import Foundation + +private enum FirebaseMessagingServiceError: LocalizedError { + case missingToken + + var errorDescription: String? { + "Firebase Messaging did not return a token." + } +} + +final class FirebaseMessagingServiceImpl: PushMessagingTokenServiceProtocol { + func setAPNSToken(_ token: Data) { + Messaging.messaging().apnsToken = token + } + + func fetchToken() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + Messaging.messaging().token { token, error in + if let error { + continuation.resume(throwing: error) + } else if let token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: FirebaseMessagingServiceError.missingToken) + } + } + } + } + + func deleteToken() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Messaging.messaging().deleteToken { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} diff --git a/Siksha/Data/Local/DeviceTokenLocalDataSource.swift b/Siksha/Data/Local/DeviceTokenLocalDataSource.swift new file mode 100644 index 00000000..5c8514ae --- /dev/null +++ b/Siksha/Data/Local/DeviceTokenLocalDataSource.swift @@ -0,0 +1,53 @@ +// +// DeviceTokenLocalDataSource.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import Foundation + +protocol DeviceTokenLocalDataSource { + func loadToken() -> String? + func saveToken(_ token: String) + func isRegistered() -> Bool + func setRegistered(_ isRegistered: Bool) + func clear() +} + +final class UserDefaultsDeviceTokenLocalDataSource: DeviceTokenLocalDataSource { + private enum Key { + static let token = "fcmToken" + static let isRegistered = "alreadySentFCM" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func loadToken() -> String? { + userDefaults.string(forKey: Key.token) + } + + func saveToken(_ token: String) { + if loadToken() != token { + userDefaults.set(false, forKey: Key.isRegistered) + } + userDefaults.set(token, forKey: Key.token) + } + + func isRegistered() -> Bool { + userDefaults.bool(forKey: Key.isRegistered) + } + + func setRegistered(_ isRegistered: Bool) { + userDefaults.set(isRegistered, forKey: Key.isRegistered) + } + + func clear() { + userDefaults.removeObject(forKey: Key.token) + userDefaults.removeObject(forKey: Key.isRegistered) + } +} diff --git a/Siksha/Data/Local/RestaurantLocalDataSource.swift b/Siksha/Data/Local/RestaurantLocalDataSource.swift index 1fb63e4e..8d5c7a78 100644 --- a/Siksha/Data/Local/RestaurantLocalDataSource.swift +++ b/Siksha/Data/Local/RestaurantLocalDataSource.swift @@ -10,6 +10,7 @@ import Foundation protocol RestaurantLocalDataSource { func savePersonalRestaurants(_ restaurants: [PersonalRestaurantDTO]) func fetchPersonalRestaurants() -> [PersonalRestaurantDTO]? + func clearPersonalRestaurants() func updateRestaurantLike(restaurantId: Int, liked: Bool) func updateRestaurantVisible(restaurantId: Int, visible: Bool) func updateRestaurantOrder(_ order: [Int]) @@ -43,6 +44,10 @@ final class RestaurantLocalDataSourceImpl: RestaurantLocalDataSource { return restaurants } + func clearPersonalRestaurants() { + userDefaults.removeObject(forKey: Key.personalRestaurants) + } + func updateRestaurantLike(restaurantId: Int, liked: Bool) { updateRestaurant(restaurantId: restaurantId) { restaurant in PersonalRestaurantDTO( diff --git a/Siksha/Data/Network/DeviceTokenRemoteDataSource.swift b/Siksha/Data/Network/DeviceTokenRemoteDataSource.swift new file mode 100644 index 00000000..4ec46491 --- /dev/null +++ b/Siksha/Data/Network/DeviceTokenRemoteDataSource.swift @@ -0,0 +1,46 @@ +// +// DeviceTokenRemoteDataSource.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import Alamofire + +protocol DeviceTokenRemoteDataSource { + func register(fcmToken: String) async throws + func unregister(fcmToken: String) async throws +} + +final class DeviceTokenRemoteDataSourceImpl: DeviceTokenRemoteDataSource { + private let session: Session + + init(session: Session = .default) { + self.session = session + } + + func register(fcmToken: String) async throws { + try await performRequest( + SikshaAPI.postUserDevice(fcmToken: fcmToken), + acceptedStatusCodes: [200, 201, 204] + ) + } + + func unregister(fcmToken: String) async throws { + try await performRequest( + SikshaAPI.deleteUserDevice(fcmToken: fcmToken), + acceptedStatusCodes: [200, 201, 204, 404] + ) + } + + private func performRequest( + _ endpoint: SikshaAPI, + acceptedStatusCodes: [Int] + ) async throws { + _ = try await session + .request(endpoint) + .validate(statusCode: acceptedStatusCodes) + .serializingData(emptyResponseCodes: Set(acceptedStatusCodes)) + .value + } +} diff --git a/Siksha/Data/Repository/DeviceTokenRepositoryImpl.swift b/Siksha/Data/Repository/DeviceTokenRepositoryImpl.swift new file mode 100644 index 00000000..2f26198f --- /dev/null +++ b/Siksha/Data/Repository/DeviceTokenRepositoryImpl.swift @@ -0,0 +1,47 @@ +// +// DeviceTokenRepositoryImpl.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +final class DeviceTokenRepositoryImpl: DeviceTokenRepositoryProtocol { + private let remote: DeviceTokenRemoteDataSource + private let local: DeviceTokenLocalDataSource + + init( + remote: DeviceTokenRemoteDataSource, + local: DeviceTokenLocalDataSource + ) { + self.remote = remote + self.local = local + } + + func registerDevice(fcmToken: String) async throws { + try await remote.register(fcmToken: fcmToken) + } + + func unregisterDevice(fcmToken: String) async throws { + try await remote.unregister(fcmToken: fcmToken) + } + + func loadDeviceToken() -> String? { + local.loadToken() + } + + func saveDeviceToken(_ token: String) { + local.saveToken(token) + } + + func isDeviceTokenRegistered() -> Bool { + local.isRegistered() + } + + func setDeviceTokenRegistered(_ isRegistered: Bool) { + local.setRegistered(isRegistered) + } + + func clearDeviceToken() { + local.clear() + } +} diff --git a/Siksha/Data/Repository/MyLikedMenuRepositoryImpl.swift b/Siksha/Data/Repository/MyLikedMenuRepositoryImpl.swift index 3e1d8ec3..6eb9cfca 100644 --- a/Siksha/Data/Repository/MyLikedMenuRepositoryImpl.swift +++ b/Siksha/Data/Repository/MyLikedMenuRepositoryImpl.swift @@ -7,7 +7,7 @@ import Foundation -final class MyLikedMenuRepositoryImpl: MyLikedMenuRepositoryProtocol { +final class MyLikedMenuRepositoryImpl: MyLikedMenuRepositoryProtocol, MenuAlarmPreferenceRepositoryProtocol { private let remote: MyLikedMenuRemoteDataSource private let local: MenuAlarmLocalDataSource diff --git a/Siksha/Data/Repository/RestaurantRepositoryImpl.swift b/Siksha/Data/Repository/RestaurantRepositoryImpl.swift index e801f5fd..f62b674a 100644 --- a/Siksha/Data/Repository/RestaurantRepositoryImpl.swift +++ b/Siksha/Data/Repository/RestaurantRepositoryImpl.swift @@ -7,7 +7,7 @@ import Foundation -final class RestaurantRepositoryImpl: RestaurantRepositoryProtocol { +final class RestaurantRepositoryImpl: RestaurantRepositoryProtocol, PersonalRestaurantStateRepositoryProtocol { private let remote: RestaurantRemoteDataSource private let local: RestaurantLocalDataSource @@ -62,6 +62,10 @@ final class RestaurantRepositoryImpl: RestaurantRepositoryProtocol { local.updateRestaurantOrder(order) return order } + + func clearPersonalRestaurants() { + local.clearPersonalRestaurants() + } } private extension PersonalRestaurantDTO { diff --git a/Siksha/Domain/Repository/MenuAlarmPreferenceRepositoryProtocol.swift b/Siksha/Domain/Repository/MenuAlarmPreferenceRepositoryProtocol.swift new file mode 100644 index 00000000..a65b3539 --- /dev/null +++ b/Siksha/Domain/Repository/MenuAlarmPreferenceRepositoryProtocol.swift @@ -0,0 +1,10 @@ +// +// MenuAlarmPreferenceRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +protocol MenuAlarmPreferenceRepositoryProtocol { + func setAlarmEnabled(_ enabled: Bool) +} diff --git a/Siksha/Domain/Repository/PersonalRestaurantStateRepositoryProtocol.swift b/Siksha/Domain/Repository/PersonalRestaurantStateRepositoryProtocol.swift new file mode 100644 index 00000000..bfe45967 --- /dev/null +++ b/Siksha/Domain/Repository/PersonalRestaurantStateRepositoryProtocol.swift @@ -0,0 +1,10 @@ +// +// PersonalRestaurantStateRepositoryProtocol.swift +// Siksha +// +// Created by Codex on 7/14/26. +// + +protocol PersonalRestaurantStateRepositoryProtocol { + func clearPersonalRestaurants() +} diff --git a/Siksha/Domain/Repository/PushMessagingTokenServiceProtocol.swift b/Siksha/Domain/Repository/PushMessagingTokenServiceProtocol.swift new file mode 100644 index 00000000..daa63651 --- /dev/null +++ b/Siksha/Domain/Repository/PushMessagingTokenServiceProtocol.swift @@ -0,0 +1,14 @@ +// +// PushMessagingTokenServiceProtocol.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import Foundation + +protocol PushMessagingTokenServiceProtocol { + func setAPNSToken(_ token: Data) + func fetchToken() async throws -> String + func deleteToken() async throws +} diff --git a/Siksha/Domain/UseCase/Auth/AccountLocalStateCleaner.swift b/Siksha/Domain/UseCase/Auth/AccountLocalStateCleaner.swift new file mode 100644 index 00000000..1e8fcebf --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/AccountLocalStateCleaner.swift @@ -0,0 +1,40 @@ +// +// AccountLocalStateCleaner.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +protocol AccountLocalStateCleaner { + func execute() async +} + +final class DefaultAccountLocalStateCleaner: AccountLocalStateCleaner { + private let messagingTokenService: PushMessagingTokenServiceProtocol + private let deviceTokenRepository: DeviceTokenRepositoryProtocol + private let authRepository: AuthRepositoryProtocol + private let menuAlarmPreferenceRepository: MenuAlarmPreferenceRepositoryProtocol + private let personalRestaurantStateRepository: PersonalRestaurantStateRepositoryProtocol + + init( + messagingTokenService: PushMessagingTokenServiceProtocol, + deviceTokenRepository: DeviceTokenRepositoryProtocol, + authRepository: AuthRepositoryProtocol, + menuAlarmPreferenceRepository: MenuAlarmPreferenceRepositoryProtocol, + personalRestaurantStateRepository: PersonalRestaurantStateRepositoryProtocol + ) { + self.messagingTokenService = messagingTokenService + self.deviceTokenRepository = deviceTokenRepository + self.authRepository = authRepository + self.menuAlarmPreferenceRepository = menuAlarmPreferenceRepository + self.personalRestaurantStateRepository = personalRestaurantStateRepository + } + + func execute() async { + try? await messagingTokenService.deleteToken() + deviceTokenRepository.clearDeviceToken() + authRepository.clearSession() + menuAlarmPreferenceRepository.setAlarmEnabled(false) + personalRestaurantStateRepository.clearPersonalRestaurants() + } +} diff --git a/Siksha/Domain/UseCase/Auth/DeleteAccountUseCase.swift b/Siksha/Domain/UseCase/Auth/DeleteAccountUseCase.swift new file mode 100644 index 00000000..fec38fb6 --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/DeleteAccountUseCase.swift @@ -0,0 +1,45 @@ +// +// DeleteAccountUseCase.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +protocol DeleteAccountUseCase { + func execute() async throws +} + +struct DeleteAccountFailure: Error { + let accountDeletionError: Error + let deviceRestorationError: Error? +} + +final class DefaultDeleteAccountUseCase: DeleteAccountUseCase { + private let deviceTokenLifecycle: DeviceTokenLifecycle + private let userRepository: UserRepositoryProtocol + private let accountLocalStateCleaner: AccountLocalStateCleaner + + init( + deviceTokenLifecycle: DeviceTokenLifecycle, + userRepository: UserRepositoryProtocol, + accountLocalStateCleaner: AccountLocalStateCleaner + ) { + self.deviceTokenLifecycle = deviceTokenLifecycle + self.userRepository = userRepository + self.accountLocalStateCleaner = accountLocalStateCleaner + } + + func execute() async throws { + do { + try await deviceTokenLifecycle.performWithCurrentDeviceUnregistered { + try await self.userRepository.deleteAccount() + await self.accountLocalStateCleaner.execute() + } + } catch let failure as DeviceTokenLifecycleOperationFailure { + throw DeleteAccountFailure( + accountDeletionError: failure.operationError, + deviceRestorationError: failure.deviceRestorationError + ) + } + } +} diff --git a/Siksha/Domain/UseCase/Auth/LogoutUseCase.swift b/Siksha/Domain/UseCase/Auth/LogoutUseCase.swift new file mode 100644 index 00000000..d14f5c8a --- /dev/null +++ b/Siksha/Domain/UseCase/Auth/LogoutUseCase.swift @@ -0,0 +1,29 @@ +// +// LogoutUseCase.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +protocol LogoutUseCase { + func execute() async throws +} + +final class DefaultLogoutUseCase: LogoutUseCase { + private let deviceTokenLifecycle: DeviceTokenLifecycle + private let accountLocalStateCleaner: AccountLocalStateCleaner + + init( + deviceTokenLifecycle: DeviceTokenLifecycle, + accountLocalStateCleaner: AccountLocalStateCleaner + ) { + self.deviceTokenLifecycle = deviceTokenLifecycle + self.accountLocalStateCleaner = accountLocalStateCleaner + } + + func execute() async throws { + try await deviceTokenLifecycle.performWithCurrentDeviceUnregistered { + await self.accountLocalStateCleaner.execute() + } + } +} diff --git a/Siksha/Domain/UseCase/DeviceToken/DeviceTokenLifecycle.swift b/Siksha/Domain/UseCase/DeviceToken/DeviceTokenLifecycle.swift new file mode 100644 index 00000000..39183af0 --- /dev/null +++ b/Siksha/Domain/UseCase/DeviceToken/DeviceTokenLifecycle.swift @@ -0,0 +1,130 @@ +// +// DeviceTokenLifecycle.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +private struct DeviceTokenSnapshot { + let token: String +} + +struct DeviceTokenLifecycleOperationFailure: Error { + let operationError: Error + let deviceRestorationError: Error? +} + +protocol DeviceTokenLifecycle { + func register(fcmToken: String) async throws + func unregisterCurrent() async throws + + func performWithCurrentDeviceUnregistered( + _ operation: () async throws -> T + ) async throws -> T +} + +final class DefaultDeviceTokenLifecycle: DeviceTokenLifecycle { + private let deviceTokenRepository: DeviceTokenRepositoryProtocol + private let authRepository: AuthRepositoryProtocol + private let operationGate: DeviceTokenOperationGate + + init( + deviceTokenRepository: DeviceTokenRepositoryProtocol, + authRepository: AuthRepositoryProtocol, + operationGate: DeviceTokenOperationGate = DeviceTokenOperationGate() + ) { + self.deviceTokenRepository = deviceTokenRepository + self.authRepository = authRepository + self.operationGate = operationGate + } + + func register(fcmToken: String) async throws { + try await operationGate.withExclusiveAccess { + guard self.authRepository.loadSession() != nil else { + return + } + + try await self.registerDevice(fcmToken: fcmToken) + } + } + + func unregisterCurrent() async throws { + try await operationGate.withExclusiveAccess { + _ = try await self.unregisterCurrentDevice() + } + } + + func performWithCurrentDeviceUnregistered( + _ operation: () async throws -> T + ) async throws -> T { + try await operationGate.withExclusiveAccess { + let snapshot = try await self.unregisterCurrentDevice() + + do { + return try await operation() + } catch let operationError { + var deviceRestorationError: Error? + + if let snapshot { + do { + try await self.restoreDevice(snapshot) + } catch { + deviceRestorationError = error + } + } + + throw DeviceTokenLifecycleOperationFailure( + operationError: operationError, + deviceRestorationError: deviceRestorationError + ) + } + } + } + + private func registerDevice(fcmToken: String) async throws { + let storedToken = deviceTokenRepository.loadDeviceToken() + let isRegistered = deviceTokenRepository.isDeviceTokenRegistered() + + if storedToken == fcmToken, isRegistered { + return + } + + if let storedToken { + try await deviceTokenRepository.unregisterDevice(fcmToken: storedToken) + } + + deviceTokenRepository.saveDeviceToken(fcmToken) + deviceTokenRepository.setDeviceTokenRegistered(false) + + do { + try await deviceTokenRepository.registerDevice(fcmToken: fcmToken) + deviceTokenRepository.setDeviceTokenRegistered(true) + } catch { + deviceTokenRepository.setDeviceTokenRegistered(false) + throw error + } + } + + private func unregisterCurrentDevice() async throws -> DeviceTokenSnapshot? { + guard let token = deviceTokenRepository.loadDeviceToken() else { + return nil + } + + try await deviceTokenRepository.unregisterDevice(fcmToken: token) + deviceTokenRepository.setDeviceTokenRegistered(false) + return DeviceTokenSnapshot(token: token) + } + + private func restoreDevice(_ snapshot: DeviceTokenSnapshot) async throws { + deviceTokenRepository.saveDeviceToken(snapshot.token) + deviceTokenRepository.setDeviceTokenRegistered(false) + + do { + try await deviceTokenRepository.registerDevice(fcmToken: snapshot.token) + deviceTokenRepository.setDeviceTokenRegistered(true) + } catch { + deviceTokenRepository.setDeviceTokenRegistered(false) + throw error + } + } +} diff --git a/Siksha/Domain/UseCase/DeviceToken/DeviceTokenOperationGate.swift b/Siksha/Domain/UseCase/DeviceToken/DeviceTokenOperationGate.swift new file mode 100644 index 00000000..6b6e84f9 --- /dev/null +++ b/Siksha/Domain/UseCase/DeviceToken/DeviceTokenOperationGate.swift @@ -0,0 +1,85 @@ +// +// DeviceTokenOperationGate.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import Foundation + +final class DeviceTokenOperationGate { + private let state = State() + + func withExclusiveAccess( + _ operation: () async throws -> T + ) async throws -> T { + let waiterID = UUID() + + try await withTaskCancellationHandler { + try await state.acquire(id: waiterID) + } onCancel: { + Task { + await self.state.cancelWaiter(id: waiterID) + } + } + + do { + try Task.checkCancellation() + } catch { + await state.release() + throw error + } + + do { + let result = try await operation() + await state.release() + return result + } catch { + await state.release() + throw error + } + } +} + +private extension DeviceTokenOperationGate { + actor State { + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private var isLocked = false + private var waiters: [Waiter] = [] + + func acquire(id: UUID) async throws { + try Task.checkCancellation() + + guard isLocked else { + isLocked = true + return + } + + try await withCheckedThrowingContinuation { continuation in + waiters.append(Waiter(id: id, continuation: continuation)) + } + } + + func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + func release() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().continuation.resume() + } + } +} diff --git a/Siksha/Domain/UseCase/DeviceToken/RegisterUserDeviceUseCase.swift b/Siksha/Domain/UseCase/DeviceToken/RegisterUserDeviceUseCase.swift new file mode 100644 index 00000000..2d4a1f57 --- /dev/null +++ b/Siksha/Domain/UseCase/DeviceToken/RegisterUserDeviceUseCase.swift @@ -0,0 +1,22 @@ +// +// RegisterUserDeviceUseCase.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +protocol RegisterUserDeviceUseCase { + func execute(fcmToken: String) async throws +} + +final class DefaultRegisterUserDeviceUseCase: RegisterUserDeviceUseCase { + private let lifecycle: DeviceTokenLifecycle + + init(lifecycle: DeviceTokenLifecycle) { + self.lifecycle = lifecycle + } + + func execute(fcmToken: String) async throws { + try await lifecycle.register(fcmToken: fcmToken) + } +} diff --git a/Siksha/Presentation/Settings/AccountManageView.swift b/Siksha/Presentation/Settings/AccountManageView.swift index abb34b85..09a08e97 100644 --- a/Siksha/Presentation/Settings/AccountManageView.swift +++ b/Siksha/Presentation/Settings/AccountManageView.swift @@ -10,10 +10,10 @@ import SwiftUI struct AccountManageView: View { @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject private var appState: AppState - @ObservedObject var viewModel:RenewalSettingsViewModel + @StateObject private var viewModel: AccountManageViewModel - init(viewModel: RenewalSettingsViewModel) { - self.viewModel = viewModel + init(viewModel: AccountManageViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) } var backButton: some View { @@ -37,7 +37,7 @@ struct AccountManageView: View { VStack { VStack(alignment: .leading, spacing: 8) { Button(action: { - viewModel.showSignOutAlert = true + viewModel.showLogoutConfirmation = true }) { HStack(alignment: .center) { Text("로그아웃") @@ -47,18 +47,12 @@ struct AccountManageView: View { Spacer() } } - .actionSheet(isPresented: $viewModel.showSignOutAlert) { + .actionSheet(isPresented: $viewModel.showLogoutConfirmation) { ActionSheet( title: Text("로그아웃"), message: Text("앱에서 로그아웃합니다."), buttons: [ - .destructive(Text("로그아웃")){ - viewModel.logOutAccount(){ result in - if result{ - transitionToLogin() - } - } - }, + .destructive(Text("로그아웃"), action: performLogout), .cancel(Text("취소")) ] ) @@ -67,7 +61,7 @@ struct AccountManageView: View { partitionBar Button(action: { - viewModel.showRemoveAccountAlert = true + viewModel.showDeleteAccountConfirmation = true }) { HStack(alignment: .center) { Text("회원탈퇴") @@ -77,17 +71,11 @@ struct AccountManageView: View { Spacer() } } - .actionSheet(isPresented: $viewModel.showRemoveAccountAlert) { + .actionSheet(isPresented: $viewModel.showDeleteAccountConfirmation) { ActionSheet(title: Text("회원 탈퇴"), message: Text("앱 계정을 삭제합니다.\n이 계정으로 등록된 리뷰 정보들도 모두 함께 삭제됩니다."), buttons: [ - .destructive(Text("회원 탈퇴")) { - viewModel.removeAccount { success in - if success { - transitionToLogin() - } - } - }, + .destructive(Text("회원 탈퇴"), action: performDeleteAccount), .cancel(Text("취소")) ] ) @@ -104,12 +92,13 @@ struct AccountManageView: View { ) .padding(.top, 24) .padding(.horizontal, 20) + .disabled(viewModel.isProcessing) Spacer() } .background(Color.backgroundPrimary) - .alert(isPresented: $viewModel.removeAccountFailed) { + .alert(isPresented: $viewModel.deleteAccountFailed) { Alert(title: Text("회원 탈퇴"), message: Text("회원 탈퇴에 실패했습니다."), dismissButton: .default(Text("확인"))) @@ -125,8 +114,22 @@ struct AccountManageView: View { } private func transitionToLogin() { - Task { @MainActor in - appState.didLogout() + appState.didLogout() + } + + private func performLogout() { + Task { + if await viewModel.logout() { + transitionToLogin() + } + } + } + + private func performDeleteAccount() { + Task { + if await viewModel.deleteAccount() { + transitionToLogin() + } } } } @@ -135,11 +138,9 @@ struct AccountManageView_Previews: PreviewProvider { static var previews: some View { Group { AccountManageView( - viewModel: RenewalSettingsViewModel( - manageRestaurantsWithoutMenuVisibilityUseCase: AppContainer.shared.useCases.manageRestaurantsWithoutMenuVisibilityUseCase, - fetchCurrentUserUseCase: AppContainer.shared.useCases.fetchCurrentUserUseCase, - submitVOCUseCase: AppContainer.shared.useCases.submitVOCUseCase, - fetchAppStoreVersionUseCase: AppContainer.shared.useCases.fetchAppStoreVersionUseCase + viewModel: AccountManageViewModel( + logoutUseCase: AppContainer.shared.useCases.logoutUseCase, + deleteAccountUseCase: AppContainer.shared.useCases.deleteAccountUseCase ) ) .environmentObject(AppState()) diff --git a/Siksha/Presentation/Settings/AccountManageViewModel.swift b/Siksha/Presentation/Settings/AccountManageViewModel.swift new file mode 100644 index 00000000..657850f1 --- /dev/null +++ b/Siksha/Presentation/Settings/AccountManageViewModel.swift @@ -0,0 +1,72 @@ +// +// AccountManageViewModel.swift +// Siksha +// +// Created by Codex on 7/13/26. +// + +import Combine +import Foundation + +@MainActor +final class AccountManageViewModel: ObservableObject { + @Published var error: AppError? + @Published var showLogoutConfirmation = false + @Published var showDeleteAccountConfirmation = false + @Published var logoutFailed = false + @Published var deleteAccountFailed = false + @Published private(set) var isProcessing = false + + private let logoutUseCase: LogoutUseCase + private let deleteAccountUseCase: DeleteAccountUseCase + + init( + logoutUseCase: LogoutUseCase, + deleteAccountUseCase: DeleteAccountUseCase + ) { + self.logoutUseCase = logoutUseCase + self.deleteAccountUseCase = deleteAccountUseCase + } + + func logout() async -> Bool { + guard beginOperation() else { return false } + defer { isProcessing = false } + + do { + try await logoutUseCase.execute() + return true + } catch { + self.error = ErrorHelper.categorize(error) + logoutFailed = true + return false + } + } + + func deleteAccount() async -> Bool { + guard beginOperation() else { return false } + defer { isProcessing = false } + + do { + try await deleteAccountUseCase.execute() + return true + } catch let failure as DeleteAccountFailure { + self.error = ErrorHelper.categorize(failure.accountDeletionError) + deleteAccountFailed = true + return false + } catch { + self.error = ErrorHelper.categorize(error) + deleteAccountFailed = true + return false + } + } + + private func beginOperation() -> Bool { + guard !isProcessing else { return false } + + error = nil + logoutFailed = false + deleteAccountFailed = false + isProcessing = true + return true + } +} diff --git a/Siksha/Presentation/Settings/RenewalSettingsView.swift b/Siksha/Presentation/Settings/RenewalSettingsView.swift index 86b822bc..fe0de23a 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsView.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsView.swift @@ -210,7 +210,12 @@ struct RenewalSettingsView: View { partitionBar - NavigationLink(destination: AccountManageView(viewModel: viewModel)) { + NavigationLink(destination: AccountManageView( + viewModel: AccountManageViewModel( + logoutUseCase: AppContainer.shared.useCases.logoutUseCase, + deleteAccountUseCase: AppContainer.shared.useCases.deleteAccountUseCase + ) + )) { HStack(alignment: .center) { Text("계정 관리") .customFont(font: .text15(weight: .Regular)) diff --git a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift index af280bc9..c7e2332f 100644 --- a/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift +++ b/Siksha/Presentation/Settings/RenewalSettingsViewModel.swift @@ -6,7 +6,6 @@ // import Combine -import FirebaseMessaging import Foundation @MainActor @@ -14,10 +13,6 @@ final class RenewalSettingsViewModel: ObservableObject { @Published var error: AppError? @Published var noMenuHide = false @Published var networkStatus: NetworkStatus = .idle - @Published var showSignOutAlert = false - @Published var showRemoveAccountAlert = false - @Published var removeAccountFailed = false - @Published var logoutFailed = false @Published var version: String @Published var appStoreVersion = "" @Published var showVOC = false @@ -28,8 +23,6 @@ final class RenewalSettingsViewModel: ObservableObject { @Published var alertMessage = "" @Published var showAlert = false - private let repository: LegacyUserRepositoryProtocol - private let authRepository: LegacyAuthRepositoryProtocol private let manageRestaurantsWithoutMenuVisibilityUseCase: ManageRestaurantsWithoutMenuVisibilityUseCase private let fetchCurrentUserUseCase: FetchCurrentUserUseCase private let submitVOCUseCase: SubmitVOCUseCase @@ -48,16 +41,12 @@ final class RenewalSettingsViewModel: ObservableObject { fetchCurrentUserUseCase: FetchCurrentUserUseCase, submitVOCUseCase: SubmitVOCUseCase, fetchAppStoreVersionUseCase: FetchAppStoreVersionUseCase, - repository: LegacyUserRepositoryProtocol = AppContainer.shared.domain.userRepository, - authRepository: LegacyAuthRepositoryProtocol = AppContainer.shared.domain.authRepository, version: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" ) { self.manageRestaurantsWithoutMenuVisibilityUseCase = manageRestaurantsWithoutMenuVisibilityUseCase self.fetchCurrentUserUseCase = fetchCurrentUserUseCase self.submitVOCUseCase = submitVOCUseCase self.fetchAppStoreVersionUseCase = fetchAppStoreVersionUseCase - self.repository = repository - self.authRepository = authRepository self.version = version noMenuHide = manageRestaurantsWithoutMenuVisibilityUseCase.shouldHideRestaurantsWithoutMenu() @@ -104,68 +93,6 @@ final class RenewalSettingsViewModel: ObservableObject { } } - func logOutAccount(completion:@escaping(Bool)->()) { - if UserDefaults.standard.string(forKey: "fcmToken") == nil{ - UserDefaults.standard.removeObject(forKey: "accessToken") - UserDefaults.standard.set(false,forKey: "isAlarmEnabled") - completion(true) - } - else{ - authRepository.deleteUserDevice(fcmToken: UserDefaults.standard.string(forKey: "fcmToken")!) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completionStatus in - switch completionStatus { - case .finished: - print("delete success fcm") - Messaging.messaging().deleteToken { error in - if let error = error { - print("Failed to delete FCM token:", error) - print(error) - self?.error = ErrorHelper.categorize(error) - self?.logoutFailed = true - completion(false) - } - else{ - print("delete done") - UserDefaults.standard.removeObject(forKey: "fcmToken") - UserDefaults.standard.removeObject(forKey: "accessToken") - UserDefaults.standard.removeObject( forKey: "alreadySentFCM") - UserDefaults.standard.set(false,forKey: "isAlarmEnabled") - completion(true) - } - } - case .failure(let error): - print("delete fail fcm") - print(error) - self?.error = ErrorHelper.categorize(error) - self?.logoutFailed = true - completion(false) - } - }, receiveValue: { _ in }) - .store(in: &cancellables) - } - } - - func removeAccount(completion: @escaping (Bool) -> Void) { - guard UserDefaults.standard.string(forKey: "accessToken") != nil else { - removeAccountFailed = true - return - } - repository.deleteUser() - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completionStatus in - switch completionStatus { - case .finished: - Utils.shared.removeAllUserDefaults() - completion(true) - case .failure(let error): - self?.error = ErrorHelper.categorize(error) - completion(false) - } - }, receiveValue: { _ in }) - .store(in: &cancellables) - } - private func loadUser() async -> Bool { do { applyUpdatedUser(try await fetchCurrentUserUseCase.execute()) diff --git a/Siksha/System/DI/AppContainer.swift b/Siksha/System/DI/AppContainer.swift index a8c1d540..4b62d421 100644 --- a/Siksha/System/DI/AppContainer.swift +++ b/Siksha/System/DI/AppContainer.swift @@ -11,18 +11,23 @@ final class AppContainer { let domain: RepositoryProvider let useCases: UseCaseProvider let socialLoginService: SocialLoginService - let menuAlarmNotificationManager: DefaultMenuAlarmNotificationManager + let menuAlarmNotificationManager: MenuAlarmNotificationManaging init() { let networkModule = AlamofireNetworking() let repository = Repository(networkModule: networkModule) let domain = RepositoryProvider(repository: repository) + let pushMessagingTokenService = FirebaseMessagingServiceImpl() + let useCases = UseCaseProvider( + pushMessagingTokenService: pushMessagingTokenService + ) self.domain = domain - self.useCases = UseCaseProvider() + self.useCases = useCases self.socialLoginService = SocialLoginServiceImpl() self.menuAlarmNotificationManager = DefaultMenuAlarmNotificationManager( - authRepository: domain.authRepository + messagingTokenService: pushMessagingTokenService, + registerUserDeviceUseCase: useCases.registerUserDeviceUseCase ) } } diff --git a/Siksha/System/DI/MenuAlarmNotificationManaging.swift b/Siksha/System/DI/MenuAlarmNotificationManaging.swift index d35ed666..af12a8b1 100644 --- a/Siksha/System/DI/MenuAlarmNotificationManaging.swift +++ b/Siksha/System/DI/MenuAlarmNotificationManaging.swift @@ -5,7 +5,14 @@ // Created by Codex on 6/30/26. // +import Foundation + protocol MenuAlarmNotificationManaging { func requestAuthorization() async -> Bool + @MainActor func registerRemoteNotificationsIfNeeded() + @MainActor + func didRegisterForRemoteNotifications(with deviceToken: Data) + @MainActor + func didFailToRegisterForRemoteNotifications(error: Error) } diff --git a/Siksha/System/DI/UseCaseProvider.swift b/Siksha/System/DI/UseCaseProvider.swift index faa7b398..37728d90 100644 --- a/Siksha/System/DI/UseCaseProvider.swift +++ b/Siksha/System/DI/UseCaseProvider.swift @@ -9,6 +9,9 @@ final class UseCaseProvider { let loginUseCase: LoginUseCase let refreshAccessTokenUseCase: RefreshAccessTokenUseCase let resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase + let registerUserDeviceUseCase: RegisterUserDeviceUseCase + let logoutUseCase: LogoutUseCase + let deleteAccountUseCase: DeleteAccountUseCase let fetchCurrentUserUseCase: FetchCurrentUserUseCase let updateUserProfileUseCase: UpdateUserProfileUseCase let submitVOCUseCase: SubmitVOCUseCase @@ -45,9 +48,14 @@ final class UseCaseProvider { let updateMenuAlarmTimeUseCase: UpdateMenuAlarmTimeUseCase init( + pushMessagingTokenService: PushMessagingTokenServiceProtocol, loginUseCase: LoginUseCase? = nil, refreshAccessTokenUseCase: RefreshAccessTokenUseCase? = nil, resolveInitialAuthStateUseCase: ResolveInitialAuthStateUseCase? = nil, + deviceTokenLifecycle: DeviceTokenLifecycle? = nil, + registerUserDeviceUseCase: RegisterUserDeviceUseCase? = nil, + logoutUseCase: LogoutUseCase? = nil, + deleteAccountUseCase: DeleteAccountUseCase? = nil, fetchCurrentUserUseCase: FetchCurrentUserUseCase? = nil, updateUserProfileUseCase: UpdateUserProfileUseCase? = nil, submitVOCUseCase: SubmitVOCUseCase? = nil, @@ -113,6 +121,10 @@ final class UseCaseProvider { remote: MyLikedMenuRemoteDataSourceImpl(), local: UserDefaultsMenuAlarmLocalDataSource() ) + let deviceTokenRepository = DeviceTokenRepositoryImpl( + remote: DeviceTokenRemoteDataSourceImpl(), + local: UserDefaultsDeviceTokenLocalDataSource() + ) let authSessionLocalDataSource = AuthSessionLocalDataSourceImpl() let authRepository = AuthRepositoryImpl( remote: AuthRemoteDataSourceImpl(), @@ -130,6 +142,23 @@ final class UseCaseProvider { let defaultRefreshAccessTokenUseCase = refreshAccessTokenUseCase ?? DefaultRefreshAccessTokenUseCase( repository: authRepository ) + let defaultSetMenuAlarmEnabledUseCase = setMenuAlarmEnabledUseCase ?? DefaultSetMenuAlarmEnabledUseCase( + repository: myLikedMenuRepository + ) + let defaultDeviceTokenLifecycle = deviceTokenLifecycle ?? DefaultDeviceTokenLifecycle( + deviceTokenRepository: deviceTokenRepository, + authRepository: authRepository + ) + let defaultRegisterUserDeviceUseCase = registerUserDeviceUseCase ?? DefaultRegisterUserDeviceUseCase( + lifecycle: defaultDeviceTokenLifecycle + ) + let accountLocalStateCleaner = DefaultAccountLocalStateCleaner( + messagingTokenService: pushMessagingTokenService, + deviceTokenRepository: deviceTokenRepository, + authRepository: authRepository, + menuAlarmPreferenceRepository: myLikedMenuRepository, + personalRestaurantStateRepository: restaurantRepository + ) self.loginUseCase = loginUseCase ?? DefaultLoginUseCase( repository: authRepository @@ -140,6 +169,16 @@ final class UseCaseProvider { refreshAccessTokenUseCase: defaultRefreshAccessTokenUseCase, appleCredentialRepository: appleCredentialRepository ) + self.registerUserDeviceUseCase = defaultRegisterUserDeviceUseCase + self.logoutUseCase = logoutUseCase ?? DefaultLogoutUseCase( + deviceTokenLifecycle: defaultDeviceTokenLifecycle, + accountLocalStateCleaner: accountLocalStateCleaner + ) + self.deleteAccountUseCase = deleteAccountUseCase ?? DefaultDeleteAccountUseCase( + deviceTokenLifecycle: defaultDeviceTokenLifecycle, + userRepository: userRepository, + accountLocalStateCleaner: accountLocalStateCleaner + ) self.fetchCurrentUserUseCase = fetchCurrentUserUseCase ?? DefaultFetchCurrentUserUseCase( repository: userRepository ) @@ -225,9 +264,7 @@ final class UseCaseProvider { self.getMenuAlarmEnabledUseCase = getMenuAlarmEnabledUseCase ?? DefaultGetMenuAlarmEnabledUseCase( repository: myLikedMenuRepository ) - self.setMenuAlarmEnabledUseCase = setMenuAlarmEnabledUseCase ?? DefaultSetMenuAlarmEnabledUseCase( - repository: myLikedMenuRepository - ) + self.setMenuAlarmEnabledUseCase = defaultSetMenuAlarmEnabledUseCase self.updateMenuAlarmUseCase = updateMenuAlarmUseCase ?? DefaultUpdateMenuAlarmUseCase( repository: myLikedMenuRepository ) diff --git a/Siksha/System/MenuAlarmNotificationManager.swift b/Siksha/System/MenuAlarmNotificationManager.swift index 7a614881..d3640996 100644 --- a/Siksha/System/MenuAlarmNotificationManager.swift +++ b/Siksha/System/MenuAlarmNotificationManager.swift @@ -7,16 +7,18 @@ import UIKit import UserNotifications -import FirebaseMessaging -import Combine final class DefaultMenuAlarmNotificationManager: MenuAlarmNotificationManaging { - private let authRepository: LegacyAuthRepositoryProtocol - private var cancellables = Set() - private var isSendingFCMToken = false + private let messagingTokenService: PushMessagingTokenServiceProtocol + private let registerUserDeviceUseCase: RegisterUserDeviceUseCase + private var registrationTask: Task? - init(authRepository: LegacyAuthRepositoryProtocol) { - self.authRepository = authRepository + init( + messagingTokenService: PushMessagingTokenServiceProtocol, + registerUserDeviceUseCase: RegisterUserDeviceUseCase + ) { + self.messagingTokenService = messagingTokenService + self.registerUserDeviceUseCase = registerUserDeviceUseCase } func requestAuthorization() async -> Bool { @@ -29,65 +31,34 @@ final class DefaultMenuAlarmNotificationManager: MenuAlarmNotificationManaging { } } + @MainActor func registerRemoteNotificationsIfNeeded() { - guard !UserDefaults.standard.bool(forKey: "alreadySentFCM") else { - return - } - - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } + UIApplication.shared.registerForRemoteNotifications() } + @MainActor func didRegisterForRemoteNotifications(with deviceToken: Data) { - guard !UserDefaults.standard.bool(forKey: "alreadySentFCM") else { + messagingTokenService.setAPNSToken(deviceToken) + + guard registrationTask == nil else { return } - - Messaging.messaging().apnsToken = deviceToken - Messaging.messaging().token { [weak self] token, error in - if let error { - print(error) - print("Token Registration fail") - return - } - - guard let token else { - print("Token Registration fail") - return + + registrationTask = Task { [weak self] in + guard let self else { return } + defer { registrationTask = nil } + + do { + let token = try await messagingTokenService.fetchToken() + try await registerUserDeviceUseCase.execute(fcmToken: token) + } catch { + // Registration remains retryable on the next APNs callback. } - - UserDefaults.standard.set(token, forKey: "fcmToken") - self?.sendFCMToken(token) } } + @MainActor func didFailToRegisterForRemoteNotifications(error: Error) { - print(error) - print("Token Registration fail") - } - - private func sendFCMToken(_ token: String) { - DispatchQueue.main.async { [weak self] in - guard let self, !self.isSendingFCMToken else { - return - } - - self.isSendingFCMToken = true - self.authRepository.postUserDevice(fcmToken: token) - .receive(on: RunLoop.main) - .sink(receiveCompletion: { [weak self] completion in - self?.isSendingFCMToken = false - - if case .failure(let error) = completion { - print(error) - print("Token Registration fail") - } - }, receiveValue: { - UserDefaults.standard.set(true, forKey: "alreadySentFCM") - print("Token Registration success") - }) - .store(in: &self.cancellables) - } + // A later app launch or alarm-enable action will request registration again. } } diff --git a/SikshaTests/AccountManageViewModelTests.swift b/SikshaTests/AccountManageViewModelTests.swift new file mode 100644 index 00000000..20f46a59 --- /dev/null +++ b/SikshaTests/AccountManageViewModelTests.swift @@ -0,0 +1,149 @@ +// +// AccountManageViewModelTests.swift +// SikshaTests +// +// Created by Codex on 7/13/26. +// + +import Foundation +import XCTest +@testable import Siksha + +@MainActor +final class AccountManageViewModelTests: XCTestCase { + func testLogoutSuccessReturnsTrueAndClearsProcessingState() async { + let logout = LogoutUseCaseStub() + let viewModel = makeViewModel(logout: logout) + + let result = await viewModel.logout() + + XCTAssertTrue(result) + XCTAssertEqual(logout.executionCount, 1) + XCTAssertFalse(viewModel.isProcessing) + XCTAssertFalse(viewModel.logoutFailed) + } + + func testLogoutFailureUpdatesFailureState() async { + let viewModel = makeViewModel( + logout: LogoutUseCaseStub(result: .failure(AccountViewModelTestError.expected)) + ) + + let result = await viewModel.logout() + + XCTAssertFalse(result) + XCTAssertTrue(viewModel.logoutFailed) + XCTAssertNotNil(viewModel.error) + XCTAssertFalse(viewModel.isProcessing) + } + + func testDeleteAccountSuccessReturnsTrue() async { + let deleteAccount = DeleteAccountUseCaseStub() + let viewModel = makeViewModel(deleteAccount: deleteAccount) + + let result = await viewModel.deleteAccount() + + XCTAssertTrue(result) + XCTAssertEqual(deleteAccount.executionCount, 1) + XCTAssertFalse(viewModel.deleteAccountFailed) + } + + func testDeleteAccountFailureUpdatesFailureState() async { + let viewModel = makeViewModel( + deleteAccount: DeleteAccountUseCaseStub( + result: .failure(AccountViewModelTestError.expected) + ) + ) + + let result = await viewModel.deleteAccount() + + XCTAssertFalse(result) + XCTAssertTrue(viewModel.deleteAccountFailed) + XCTAssertNotNil(viewModel.error) + } + + func testDeleteAccountFailureUsesOriginalDeletionErrorForPresentation() async { + let deletionError = URLError(.notConnectedToInternet) + let failure = DeleteAccountFailure( + accountDeletionError: deletionError, + deviceRestorationError: AccountViewModelTestError.expected + ) + let viewModel = makeViewModel( + deleteAccount: DeleteAccountUseCaseStub(result: .failure(failure)) + ) + + let result = await viewModel.deleteAccount() + + XCTAssertFalse(result) + XCTAssertEqual( + viewModel.error?.errorDescription, + ErrorHelper.categorize(deletionError).errorDescription + ) + } + + func testConcurrentActionIsRejected() async { + let logout = LogoutUseCaseStub(delayNanoseconds: 100_000_000) + let deleteAccount = DeleteAccountUseCaseStub() + let viewModel = makeViewModel(logout: logout, deleteAccount: deleteAccount) + + let logoutTask = Task { await viewModel.logout() } + await Task.yield() + + let deleteResult = await viewModel.deleteAccount() + let logoutResult = await logoutTask.value + + XCTAssertFalse(deleteResult) + XCTAssertTrue(logoutResult) + XCTAssertEqual(logout.executionCount, 1) + XCTAssertEqual(deleteAccount.executionCount, 0) + } + + private func makeViewModel( + logout: LogoutUseCaseStub = LogoutUseCaseStub(), + deleteAccount: DeleteAccountUseCaseStub = DeleteAccountUseCaseStub() + ) -> AccountManageViewModel { + AccountManageViewModel( + logoutUseCase: logout, + deleteAccountUseCase: deleteAccount + ) + } +} + +private enum AccountViewModelTestError: Error { + case expected +} + +private final class LogoutUseCaseStub: LogoutUseCase { + private let result: Result + private let delayNanoseconds: UInt64 + private(set) var executionCount = 0 + + init( + result: Result = .success(()), + delayNanoseconds: UInt64 = 0 + ) { + self.result = result + self.delayNanoseconds = delayNanoseconds + } + + func execute() async throws { + executionCount += 1 + if delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: delayNanoseconds) + } + try result.get() + } +} + +private final class DeleteAccountUseCaseStub: DeleteAccountUseCase { + private let result: Result + private(set) var executionCount = 0 + + init(result: Result = .success(())) { + self.result = result + } + + func execute() async throws { + executionCount += 1 + try result.get() + } +} diff --git a/SikshaTests/AccountUseCaseTests.swift b/SikshaTests/AccountUseCaseTests.swift new file mode 100644 index 00000000..74c7d207 --- /dev/null +++ b/SikshaTests/AccountUseCaseTests.swift @@ -0,0 +1,546 @@ +// +// AccountUseCaseTests.swift +// SikshaTests +// +// Created by Codex on 7/13/26. +// + +import Foundation +import XCTest +@testable import Siksha + +final class AccountUseCaseTests: XCTestCase { + func testAccountCleanupContinuesWhenMessagingTokenDeletionFails() async { + let messaging = MessagingTokenServiceStub( + deleteResult: .failure(AccountTestError.firebase) + ) + let deviceRepository = AccountDeviceTokenRepositorySpy( + token: "token", + isRegistered: true + ) + let authRepository = AccountAuthRepositoryStub(hasSession: true) + let alarmRepository = MenuAlarmPreferenceRepositorySpy() + let restaurantStateRepository = PersonalRestaurantStateRepositorySpy() + let cleaner = DefaultAccountLocalStateCleaner( + messagingTokenService: messaging, + deviceTokenRepository: deviceRepository, + authRepository: authRepository, + menuAlarmPreferenceRepository: alarmRepository, + personalRestaurantStateRepository: restaurantStateRepository + ) + + await cleaner.execute() + + XCTAssertEqual(messaging.deleteExecutionCount, 1) + XCTAssertNil(deviceRepository.token) + XCTAssertNil(authRepository.loadSession()) + XCTAssertEqual(alarmRepository.values, [false]) + XCTAssertEqual(restaurantStateRepository.clearExecutionCount, 1) + } + + func testLogoutFailureSkipsCleanup() async { + let log = AccountOperationLog() + let repository = AccountDeviceTokenRepositorySpy( + token: "token", + isRegistered: true, + unregisterResult: .failure(AccountTestError.unregister), + log: log + ) + let cleaner = AccountLocalStateCleanerSpy(log: log) + let useCase = DefaultLogoutUseCase( + deviceTokenLifecycle: makeLifecycle(repository: repository), + accountLocalStateCleaner: cleaner + ) + + await assertThrows(AccountTestError.unregister) { + try await useCase.execute() + } + + XCTAssertEqual(log.values, ["unregister"]) + XCTAssertEqual(cleaner.executionCount, 0) + } + + func testLogoutSuccessUnregistersBeforeCleanup() async throws { + let log = AccountOperationLog() + let repository = AccountDeviceTokenRepositorySpy( + token: "token", + isRegistered: true, + log: log + ) + let useCase = DefaultLogoutUseCase( + deviceTokenLifecycle: makeLifecycle(repository: repository), + accountLocalStateCleaner: AccountLocalStateCleanerSpy(log: log) + ) + + try await useCase.execute() + + XCTAssertEqual(log.values, ["unregister", "cleanup"]) + } + + func testDeleteAccountSuccessUnregistersBeforeDeleteAndCleanup() async throws { + let dependencies = makeDeleteDependencies() + let useCase = makeDeleteAccountUseCase(dependencies) + + try await useCase.execute() + + XCTAssertEqual( + dependencies.log.values, + ["unregister", "deleteAccount", "cleanup"] + ) + XCTAssertFalse(dependencies.deviceRepository.isRegistered) + } + + func testDeleteAccountUnregisterFailureSkipsDeleteAndCleanup() async { + let dependencies = makeDeleteDependencies( + unregisterResult: .failure(AccountTestError.unregister) + ) + let useCase = makeDeleteAccountUseCase(dependencies) + + await assertThrows(AccountTestError.unregister) { + try await useCase.execute() + } + + XCTAssertEqual(dependencies.log.values, ["unregister"]) + XCTAssertEqual(dependencies.cleaner.executionCount, 0) + } + + func testDeleteAccountFailureRestoresDeviceAndPreservesOriginalError() async { + let dependencies = makeDeleteDependencies( + deleteResult: .failure(AccountTestError.delete) + ) + let useCase = makeDeleteAccountUseCase(dependencies) + + await assertDeleteFailure( + deletionError: .delete, + restorationError: nil + ) { + try await useCase.execute() + } + + XCTAssertEqual( + dependencies.log.values, + ["unregister", "deleteAccount", "register:token"] + ) + XCTAssertTrue(dependencies.deviceRepository.isRegistered) + XCTAssertEqual(dependencies.cleaner.executionCount, 0) + } + + func testDeleteAccountRestoreFailurePreservesBothErrors() async { + let dependencies = makeDeleteDependencies( + deleteResult: .failure(AccountTestError.delete), + registerResult: .failure(AccountTestError.register) + ) + let useCase = makeDeleteAccountUseCase(dependencies) + + await assertDeleteFailure( + deletionError: .delete, + restorationError: .register + ) { + try await useCase.execute() + } + + XCTAssertEqual( + dependencies.log.values, + ["unregister", "deleteAccount", "register:token"] + ) + XCTAssertFalse(dependencies.deviceRepository.isRegistered) + XCTAssertEqual(dependencies.cleaner.executionCount, 0) + } + + func testLogoutWaitsForInFlightRegistration() async throws { + let log = AccountOperationLog() + let repository = AccountDeviceTokenRepositorySpy(log: log) + let authRepository = AccountAuthRepositoryStub(hasSession: true) + let lifecycle = makeLifecycle( + repository: repository, + authRepository: authRepository + ) + let registrationStarted = expectation(description: "registration started") + let registrationSignal = AsyncSignal() + repository.registerHandler = { + registrationStarted.fulfill() + await registrationSignal.wait() + } + let registerUseCase = DefaultRegisterUserDeviceUseCase(lifecycle: lifecycle) + let logoutUseCase = DefaultLogoutUseCase( + deviceTokenLifecycle: lifecycle, + accountLocalStateCleaner: AccountLocalStateCleanerSpy(log: log) + ) + + let registrationTask = Task { + try await registerUseCase.execute(fcmToken: "token") + } + await fulfillment(of: [registrationStarted], timeout: 1) + + let logoutTask = Task { + try await logoutUseCase.execute() + } + await Task.yield() + XCTAssertEqual(log.values, ["register:token"]) + + await registrationSignal.resume() + try await registrationTask.value + try await logoutTask.value + + XCTAssertEqual(log.values, ["register:token", "unregister", "cleanup"]) + } + + func testOperationGateSerializesAsyncOperations() async throws { + let gate = DeviceTokenOperationGate() + let signal = AsyncSignal() + let firstStarted = expectation(description: "first operation started") + let log = AccountOperationLog() + + let firstTask = Task { + try await gate.withExclusiveAccess { + log.append("first-start") + firstStarted.fulfill() + await signal.wait() + log.append("first-end") + } + } + + await fulfillment(of: [firstStarted], timeout: 1) + + let secondTask = Task { + try await gate.withExclusiveAccess { + log.append("second") + } + } + + await Task.yield() + XCTAssertEqual(log.values, ["first-start"]) + + await signal.resume() + try await firstTask.value + try await secondTask.value + + XCTAssertEqual(log.values, ["first-start", "first-end", "second"]) + } + + func testOperationGateDoesNotRunCancelledWaiter() async throws { + let gate = DeviceTokenOperationGate() + let signal = AsyncSignal() + let firstStarted = expectation(description: "first operation started") + let secondStarted = expectation(description: "second task started") + let log = AccountOperationLog() + + let firstTask = Task { + try await gate.withExclusiveAccess { + log.append("first-start") + firstStarted.fulfill() + await signal.wait() + log.append("first-end") + } + } + await fulfillment(of: [firstStarted], timeout: 1) + + let secondTask = Task { + secondStarted.fulfill() + try await gate.withExclusiveAccess { + log.append("second") + } + } + await fulfillment(of: [secondStarted], timeout: 1) + secondTask.cancel() + + await signal.resume() + try await firstTask.value + + do { + try await secondTask.value + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected cancellation while waiting for exclusive access. + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(log.values, ["first-start", "first-end"]) + } + + private func makeDeleteDependencies( + unregisterResult: Result = .success(()), + deleteResult: Result = .success(()), + registerResult: Result = .success(()) + ) -> DeleteDependencies { + let log = AccountOperationLog() + let deviceRepository = AccountDeviceTokenRepositorySpy( + token: "token", + isRegistered: true, + registerResult: registerResult, + unregisterResult: unregisterResult, + log: log + ) + let authRepository = AccountAuthRepositoryStub(hasSession: true) + + return DeleteDependencies( + log: log, + deviceRepository: deviceRepository, + lifecycle: makeLifecycle( + repository: deviceRepository, + authRepository: authRepository + ), + userRepository: AccountUserRepositorySpy( + deleteResult: deleteResult, + log: log + ), + cleaner: AccountLocalStateCleanerSpy(log: log) + ) + } + + private func makeDeleteAccountUseCase( + _ dependencies: DeleteDependencies + ) -> DefaultDeleteAccountUseCase { + DefaultDeleteAccountUseCase( + deviceTokenLifecycle: dependencies.lifecycle, + userRepository: dependencies.userRepository, + accountLocalStateCleaner: dependencies.cleaner + ) + } + + private func makeLifecycle( + repository: AccountDeviceTokenRepositorySpy, + authRepository: AccountAuthRepositoryStub = AccountAuthRepositoryStub(hasSession: true) + ) -> DefaultDeviceTokenLifecycle { + DefaultDeviceTokenLifecycle( + deviceTokenRepository: repository, + authRepository: authRepository + ) + } + + private func assertThrows( + _ expectedError: AccountTestError, + operation: () async throws -> Void + ) async { + do { + try await operation() + XCTFail("Expected error: \(expectedError)") + } catch let error as AccountTestError { + XCTAssertEqual(error, expectedError) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func assertDeleteFailure( + deletionError: AccountTestError, + restorationError: AccountTestError?, + operation: () async throws -> Void + ) async { + do { + try await operation() + XCTFail("Expected DeleteAccountFailure") + } catch let failure as DeleteAccountFailure { + XCTAssertEqual( + failure.accountDeletionError as? AccountTestError, + deletionError + ) + XCTAssertEqual( + failure.deviceRestorationError as? AccountTestError, + restorationError + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} + +private struct DeleteDependencies { + let log: AccountOperationLog + let deviceRepository: AccountDeviceTokenRepositorySpy + let lifecycle: DefaultDeviceTokenLifecycle + let userRepository: AccountUserRepositorySpy + let cleaner: AccountLocalStateCleanerSpy +} + +private enum AccountTestError: Error, Equatable { + case firebase + case unregister + case delete + case register + case unexpected +} + +private final class AccountOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var storedValues: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storedValues + } + + func append(_ value: String) { + lock.lock() + storedValues.append(value) + lock.unlock() + } +} + +private actor AsyncSignal { + private var continuation: CheckedContinuation? + private var isSignaled = false + + func wait() async { + guard !isSignaled else { return } + await withCheckedContinuation { continuation = $0 } + } + + func resume() { + if let continuation { + self.continuation = nil + continuation.resume() + } else { + isSignaled = true + } + } +} + +private final class MessagingTokenServiceStub: PushMessagingTokenServiceProtocol { + private let deleteResult: Result + private(set) var deleteExecutionCount = 0 + + init(deleteResult: Result) { + self.deleteResult = deleteResult + } + + func setAPNSToken(_ token: Data) {} + func fetchToken() async throws -> String { "token" } + + func deleteToken() async throws { + deleteExecutionCount += 1 + try deleteResult.get() + } +} + +private final class AccountDeviceTokenRepositorySpy: DeviceTokenRepositoryProtocol { + var token: String? + var isRegistered: Bool + var registerHandler: (() async -> Void)? + + private let registerResult: Result + private let unregisterResult: Result + private let log: AccountOperationLog? + + init( + token: String? = nil, + isRegistered: Bool = false, + registerResult: Result = .success(()), + unregisterResult: Result = .success(()), + log: AccountOperationLog? = nil + ) { + self.token = token + self.isRegistered = isRegistered + self.registerResult = registerResult + self.unregisterResult = unregisterResult + self.log = log + } + + func registerDevice(fcmToken: String) async throws { + log?.append("register:\(fcmToken)") + await registerHandler?() + try registerResult.get() + } + + func unregisterDevice(fcmToken: String) async throws { + log?.append("unregister") + try unregisterResult.get() + } + + func loadDeviceToken() -> String? { token } + + func saveDeviceToken(_ token: String) { + if self.token != token { + isRegistered = false + } + self.token = token + } + + func isDeviceTokenRegistered() -> Bool { isRegistered } + func setDeviceTokenRegistered(_ isRegistered: Bool) { self.isRegistered = isRegistered } + + func clearDeviceToken() { + token = nil + isRegistered = false + } +} + +private final class AccountAuthRepositoryStub: AuthRepositoryProtocol { + private var session: AuthSession? + + init(hasSession: Bool) { + session = hasSession ? AuthSession(accessToken: "access-token", expiresAt: nil) : nil + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + throw AccountTestError.unexpected + } + + func loginForTest() async throws -> AuthSession { throw AccountTestError.unexpected } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + throw AccountTestError.unexpected + } + + func loadSession() -> AuthSession? { session } + func saveSession(_ session: AuthSession) { self.session = session } + func clearSession() { session = nil } +} + +private final class MenuAlarmPreferenceRepositorySpy: MenuAlarmPreferenceRepositoryProtocol { + private(set) var values: [Bool] = [] + func setAlarmEnabled(_ enabled: Bool) { values.append(enabled) } +} + +private final class PersonalRestaurantStateRepositorySpy: PersonalRestaurantStateRepositoryProtocol { + private(set) var clearExecutionCount = 0 + + func clearPersonalRestaurants() { + clearExecutionCount += 1 + } +} + +private final class AccountUserRepositorySpy: UserRepositoryProtocol { + private let deleteResult: Result + private let log: AccountOperationLog + + init(deleteResult: Result, log: AccountOperationLog) { + self.deleteResult = deleteResult + self.log = log + } + + func fetchCurrentUser() async throws -> User { throw AccountTestError.unexpected } + + func updateProfile( + nickname: String?, + image: Data?, + changeToDefaultImage: Bool + ) async throws -> User { + throw AccountTestError.unexpected + } + + func submitVOC(comment: String, platform: String) async throws { + throw AccountTestError.unexpected + } + + func deleteAccount() async throws { + log.append("deleteAccount") + try deleteResult.get() + } +} + +private final class AccountLocalStateCleanerSpy: AccountLocalStateCleaner { + private let log: AccountOperationLog + private(set) var executionCount = 0 + + init(log: AccountOperationLog) { + self.log = log + } + + func execute() async { + executionCount += 1 + log.append("cleanup") + } +} diff --git a/SikshaTests/DeviceTokenRemoteDataSourceTests.swift b/SikshaTests/DeviceTokenRemoteDataSourceTests.swift new file mode 100644 index 00000000..e7123e0c --- /dev/null +++ b/SikshaTests/DeviceTokenRemoteDataSourceTests.swift @@ -0,0 +1,76 @@ +// +// DeviceTokenRemoteDataSourceTests.swift +// SikshaTests +// +// Created by Codex on 7/13/26. +// + +import Alamofire +import XCTest +@testable import Siksha + +final class DeviceTokenRemoteDataSourceTests: XCTestCase { + override func tearDown() { + URLProtocolStub.statusCode = 200 + super.tearDown() + } + + func testRegisterAcceptsEmptySuccessResponses() async throws { + for statusCode in [200, 201, 204] { + URLProtocolStub.statusCode = statusCode + let dataSource = makeDataSource() + + try await dataSource.register(fcmToken: "token") + } + } + + func testUnregisterTreatsNotFoundAsIdempotentSuccess() async throws { + for statusCode in [200, 204, 404] { + URLProtocolStub.statusCode = statusCode + let dataSource = makeDataSource() + + try await dataSource.unregister(fcmToken: "token") + } + } + + func testServerFailureIsPropagated() async { + URLProtocolStub.statusCode = 500 + let dataSource = makeDataSource() + + do { + try await dataSource.unregister(fcmToken: "token") + XCTFail("Expected HTTP validation failure") + } catch { + XCTAssertNotNil(error as? AFError) + } + } + + private func makeDataSource() -> DeviceTokenRemoteDataSourceImpl { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return DeviceTokenRemoteDataSourceImpl( + session: Session(configuration: configuration) + ) + } +} + +private final class URLProtocolStub: URLProtocol { + static var statusCode = 200 + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: Self.statusCode, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/SikshaTests/DeviceTokenUseCaseTests.swift b/SikshaTests/DeviceTokenUseCaseTests.swift new file mode 100644 index 00000000..89864023 --- /dev/null +++ b/SikshaTests/DeviceTokenUseCaseTests.swift @@ -0,0 +1,224 @@ +// +// DeviceTokenUseCaseTests.swift +// SikshaTests +// +// Created by Codex on 7/13/26. +// + +import XCTest +@testable import Siksha + +final class DeviceTokenUseCaseTests: XCTestCase { + func testRegisterWithoutSessionSkipsLocalAndRemoteWork() async throws { + let repository = DeviceTokenRepositorySpy() + let useCase = makeRegisterUseCase(repository: repository, hasSession: false) + + try await useCase.execute(fcmToken: "new-token") + + XCTAssertNil(repository.token) + XCTAssertTrue(repository.remoteEvents.isEmpty) + } + + func testRegisterSameRegisteredTokenSkipsRemoteWork() async throws { + let repository = DeviceTokenRepositorySpy(token: "token", isRegistered: true) + let useCase = makeRegisterUseCase(repository: repository) + + try await useCase.execute(fcmToken: "token") + + XCTAssertTrue(repository.remoteEvents.isEmpty) + XCTAssertTrue(repository.isRegistered) + } + + func testRegisterSameUnconfirmedTokenDeletesThenPosts() async throws { + let repository = DeviceTokenRepositorySpy(token: "token", isRegistered: false) + let useCase = makeRegisterUseCase(repository: repository) + + try await useCase.execute(fcmToken: "token") + + XCTAssertEqual(repository.remoteEvents, ["delete:token", "post:token"]) + XCTAssertTrue(repository.isRegistered) + } + + func testRegisterChangedTokenDeletesOldTokenBeforePostingNewToken() async throws { + let repository = DeviceTokenRepositorySpy(token: "old-token", isRegistered: true) + let useCase = makeRegisterUseCase(repository: repository) + + try await useCase.execute(fcmToken: "new-token") + + XCTAssertEqual(repository.remoteEvents, ["delete:old-token", "post:new-token"]) + XCTAssertEqual(repository.token, "new-token") + XCTAssertTrue(repository.isRegistered) + } + + func testRegisterPreservesOldStateWhenOldTokenDeleteFails() async { + let repository = DeviceTokenRepositorySpy(token: "old-token", isRegistered: true) + repository.unregisterResult = .failure(DeviceTokenTestError.deleteFailed) + let useCase = makeRegisterUseCase(repository: repository) + + await assertThrows(DeviceTokenTestError.deleteFailed) { + try await useCase.execute(fcmToken: "new-token") + } + + XCTAssertEqual(repository.remoteEvents, ["delete:old-token"]) + XCTAssertEqual(repository.token, "old-token") + XCTAssertTrue(repository.isRegistered) + } + + func testRegisterKeepsNewTokenUnregisteredWhenPostFails() async { + let repository = DeviceTokenRepositorySpy(token: "old-token", isRegistered: true) + repository.registerResult = .failure(DeviceTokenTestError.postFailed) + let useCase = makeRegisterUseCase(repository: repository) + + await assertThrows(DeviceTokenTestError.postFailed) { + try await useCase.execute(fcmToken: "new-token") + } + + XCTAssertEqual(repository.remoteEvents, ["delete:old-token", "post:new-token"]) + XCTAssertEqual(repository.token, "new-token") + XCTAssertFalse(repository.isRegistered) + } + + func testUnregisterWithoutTokenSkipsRemoteWork() async throws { + let repository = DeviceTokenRepositorySpy() + let lifecycle = makeDeviceTokenLifecycle(repository: repository) + + try await lifecycle.unregisterCurrent() + + XCTAssertTrue(repository.remoteEvents.isEmpty) + } + + func testUnregisterCallsDeleteEvenWhenRegistrationIsUnconfirmed() async throws { + let repository = DeviceTokenRepositorySpy(token: "token", isRegistered: false) + let lifecycle = makeDeviceTokenLifecycle(repository: repository) + + try await lifecycle.unregisterCurrent() + + XCTAssertEqual(repository.remoteEvents, ["delete:token"]) + XCTAssertFalse(repository.isRegistered) + } + + func testUnregisterFailurePreservesRegistrationState() async { + let repository = DeviceTokenRepositorySpy(token: "token", isRegistered: true) + repository.unregisterResult = .failure(DeviceTokenTestError.deleteFailed) + let lifecycle = makeDeviceTokenLifecycle(repository: repository) + + await assertThrows(DeviceTokenTestError.deleteFailed) { + try await lifecycle.unregisterCurrent() + } + + XCTAssertEqual(repository.token, "token") + XCTAssertTrue(repository.isRegistered) + } + + private func makeRegisterUseCase( + repository: DeviceTokenRepositorySpy, + hasSession: Bool = true + ) -> DefaultRegisterUserDeviceUseCase { + let lifecycle = DefaultDeviceTokenLifecycle( + deviceTokenRepository: repository, + authRepository: AuthRepositorySessionStub(hasSession: hasSession) + ) + + return DefaultRegisterUserDeviceUseCase( + lifecycle: lifecycle + ) + } + + private func makeDeviceTokenLifecycle( + repository: DeviceTokenRepositorySpy + ) -> DefaultDeviceTokenLifecycle { + DefaultDeviceTokenLifecycle( + deviceTokenRepository: repository, + authRepository: AuthRepositorySessionStub(hasSession: true) + ) + } + + private func assertThrows( + _ expectedError: DeviceTokenTestError, + operation: () async throws -> Void + ) async { + do { + try await operation() + XCTFail("Expected error: \(expectedError)") + } catch let error as DeviceTokenTestError { + XCTAssertEqual(error, expectedError) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} + +private enum DeviceTokenTestError: Error, Equatable { + case postFailed + case deleteFailed + case unexpected +} + +private final class DeviceTokenRepositorySpy: DeviceTokenRepositoryProtocol { + var token: String? + var isRegistered: Bool + var registerResult: Result = .success(()) + var unregisterResult: Result = .success(()) + private(set) var remoteEvents: [String] = [] + + init(token: String? = nil, isRegistered: Bool = false) { + self.token = token + self.isRegistered = isRegistered + } + + func registerDevice(fcmToken: String) async throws { + remoteEvents.append("post:\(fcmToken)") + try registerResult.get() + } + + func unregisterDevice(fcmToken: String) async throws { + remoteEvents.append("delete:\(fcmToken)") + try unregisterResult.get() + } + + func loadDeviceToken() -> String? { token } + + func saveDeviceToken(_ token: String) { + if self.token != token { + isRegistered = false + } + self.token = token + } + + func isDeviceTokenRegistered() -> Bool { isRegistered } + + func setDeviceTokenRegistered(_ isRegistered: Bool) { + self.isRegistered = isRegistered + } + + func clearDeviceToken() { + token = nil + isRegistered = false + } +} + +private final class AuthRepositorySessionStub: AuthRepositoryProtocol { + private var session: AuthSession? + + init(hasSession: Bool) { + session = hasSession + ? AuthSession(accessToken: "access-token", expiresAt: nil) + : nil + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + throw DeviceTokenTestError.unexpected + } + + func loginForTest() async throws -> AuthSession { + throw DeviceTokenTestError.unexpected + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + throw DeviceTokenTestError.unexpected + } + + func loadSession() -> AuthSession? { session } + func saveSession(_ session: AuthSession) { self.session = session } + func clearSession() { session = nil } +} diff --git a/SikshaTests/RenewalSettingsViewModelTests.swift b/SikshaTests/RenewalSettingsViewModelTests.swift index ecc70325..672391e1 100644 --- a/SikshaTests/RenewalSettingsViewModelTests.swift +++ b/SikshaTests/RenewalSettingsViewModelTests.swift @@ -3,7 +3,6 @@ // SikshaTests // -import Combine import XCTest @testable import Siksha @@ -74,8 +73,6 @@ final class RenewalSettingsViewModelTests: XCTestCase { fetchCurrentUserUseCase: fetchUser, submitVOCUseCase: submitVOC, fetchAppStoreVersionUseCase: fetchVersion, - repository: LegacyUserRepositoryStub(), - authRepository: LegacyAuthRepositoryStub(), version: "1.0.0" ) } @@ -138,18 +135,6 @@ private final class ManageRestaurantsWithoutMenuVisibilityUseCaseStub: ManageRes func setShouldHideRestaurantsWithoutMenu(_ shouldHide: Bool) {} } -private final class LegacyUserRepositoryStub: LegacyUserRepositoryProtocol { - func loadUserInfo() -> AnyPublisher { Empty().eraseToAnyPublisher() } - func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher { Empty().eraseToAnyPublisher() } - func submitVOC(comment: String, platform: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } - func deleteUser() -> AnyPublisher { Empty().eraseToAnyPublisher() } -} - -private final class LegacyAuthRepositoryStub: LegacyAuthRepositoryProtocol { - func postUserDevice(fcmToken: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } - func deleteUserDevice(fcmToken: String) -> AnyPublisher { Empty().eraseToAnyPublisher() } -} - private extension User { static func settingsFixture(id: Int = 10, nickname: String? = "설정 닉네임") -> User { User( diff --git a/SikshaTests/RestaurantLocalDataSourceTests.swift b/SikshaTests/RestaurantLocalDataSourceTests.swift new file mode 100644 index 00000000..9435193a --- /dev/null +++ b/SikshaTests/RestaurantLocalDataSourceTests.swift @@ -0,0 +1,60 @@ +// +// RestaurantLocalDataSourceTests.swift +// SikshaTests +// +// Created by Codex on 7/14/26. +// + +import Foundation +import XCTest +@testable import Siksha + +final class RestaurantLocalDataSourceTests: XCTestCase { + private var suiteName: String! + private var userDefaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "RestaurantLocalDataSourceTests.\(UUID().uuidString)" + userDefaults = UserDefaults(suiteName: suiteName) + userDefaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults = nil + suiteName = nil + super.tearDown() + } + + func testClearPersonalRestaurantsRemovesOnlyPersonalRestaurantState() { + let dataSource = RestaurantLocalDataSourceImpl(userDefaults: userDefaults) + let unrelatedKey = "unrelated-preference" + userDefaults.set(true, forKey: unrelatedKey) + dataSource.savePersonalRestaurants([makeRestaurant(id: 1)]) + + XCTAssertEqual(dataSource.fetchPersonalRestaurants()?.map(\.id), [1]) + + dataSource.clearPersonalRestaurants() + + XCTAssertNil(dataSource.fetchPersonalRestaurants()) + XCTAssertTrue(userDefaults.bool(forKey: unrelatedKey)) + } + + private func makeRestaurant(id: Int) -> PersonalRestaurantDTO { + PersonalRestaurantDTO( + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + id: id, + code: "restaurant-\(id)", + nameKr: "Restaurant KR \(id)", + nameEn: "Restaurant \(id)", + addr: nil, + lat: nil, + lng: nil, + liked: true, + visible: true, + etc: nil + ) + } +} From e8f4ef6f0546d9a01fdac05c820bd8eaa9208c23 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Tue, 14 Jul 2026 23:50:52 +0900 Subject: [PATCH 11/14] fix(auth): run social login flows on MainActor --- Siksha/Data/External/SocialLoginService.swift | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/Siksha/Data/External/SocialLoginService.swift b/Siksha/Data/External/SocialLoginService.swift index c6e78bd2..7779c1c0 100644 --- a/Siksha/Data/External/SocialLoginService.swift +++ b/Siksha/Data/External/SocialLoginService.swift @@ -13,6 +13,7 @@ import KakaoSDKUser import UIKit protocol SocialLoginService: AnyObject { + @MainActor func login( provider: LoginProvider, presentingViewController: UIViewController? @@ -29,6 +30,7 @@ enum SocialLoginServiceError: Error { final class SocialLoginServiceImpl: SocialLoginService { private var appleAuthorizationCoordinator: AppleAuthorizationCoordinator? + @MainActor func login( provider: LoginProvider, presentingViewController: UIViewController? @@ -52,6 +54,7 @@ final class SocialLoginServiceImpl: SocialLoginService { return GIDSignIn.sharedInstance.handle(url) } + @MainActor private func loginWithKakao() async throws -> LoginCredential { let oauthToken = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let completion: (OAuthToken?, Error?) -> Void = { oauthToken, error in @@ -78,6 +81,7 @@ final class SocialLoginServiceImpl: SocialLoginService { return LoginCredential(provider: .kakao, token: oauthToken.accessToken) } + @MainActor private func loginWithGoogle( presentingViewController: UIViewController? ) async throws -> LoginCredential { @@ -85,21 +89,9 @@ final class SocialLoginServiceImpl: SocialLoginService { throw SocialLoginServiceError.missingPresentingViewController } - let signInResult = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { signInResult, error in - if let error { - continuation.resume(throwing: error) - return - } - - guard let signInResult else { - continuation.resume(throwing: SocialLoginServiceError.missingCredential) - return - } - - continuation.resume(returning: signInResult) - } - } + let signInResult = try await GIDSignIn.sharedInstance.signIn( + withPresenting: presentingViewController + ) guard let token = signInResult.user.idToken?.tokenString else { throw SocialLoginServiceError.missingCredential @@ -108,6 +100,7 @@ final class SocialLoginServiceImpl: SocialLoginService { return LoginCredential(provider: .google, token: token) } + @MainActor private func loginWithApple( presentingViewController: UIViewController? ) async throws -> LoginCredential { @@ -123,6 +116,7 @@ final class SocialLoginServiceImpl: SocialLoginService { } } +@MainActor private final class AppleAuthorizationCoordinator: NSObject { private weak var presentingViewController: UIViewController? private var continuation: CheckedContinuation? From 4059046d09ad364d07521c45984beb26bb8dd017 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Thu, 16 Jul 2026 00:08:55 +0900 Subject: [PATCH 12/14] refactor(auth): remove legacy auth repository surface --- Siksha.xcodeproj/project.pbxproj | 2 +- Siksha/Data/Network/Networking.swift | 32 ------------ Siksha/Data/Repository/Repository.swift | 36 +------------ .../Data/Repository/RepositoryProtocol.swift | 17 ------ Siksha/Library/Utils.swift | 52 ------------------- Siksha/System/DI/RepositoryProvider.swift | 14 +---- 6 files changed, 4 insertions(+), 149 deletions(-) delete mode 100644 Siksha/Data/Network/Networking.swift delete mode 100644 Siksha/Library/Utils.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index f8cd25de..9f06643a 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -85,8 +85,8 @@ DeviceTokenUseCaseTests.swift, ProfileEditViewModelTests.swift, RemoteImageLoaderTests.swift, - RestaurantLocalDataSourceTests.swift, RenewalSettingsViewModelTests.swift, + RestaurantLocalDataSourceTests.swift, SikshaTests.swift, ); target = 1E3A56FC25C6FCE900DBB3F4 /* SikshaTests */; diff --git a/Siksha/Data/Network/Networking.swift b/Siksha/Data/Network/Networking.swift deleted file mode 100644 index 2351937d..00000000 --- a/Siksha/Data/Network/Networking.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Networking.swift -// Siksha -// -// Created by 박종석 on 2021/05/07. -// - -import Foundation -import Alamofire - -class Networking { - static let shared = Networking() - - private init() {} - - func testLogin() -> DataResponsePublisher { - let request = AF.request(SikshaAPI.testLogin) - return request.validate().publishData() - } - - func getAccessToken(token: String, endPoint: String) -> DataResponsePublisher { - let request = AF.request(SikshaAPI.getAccessToken(token: token, endPoint: endPoint)) - - return request.validate().publishData() - } - - func refreshAccessToken(token: String) -> DataResponsePublisher { - let request = AF.request(SikshaAPI.refreshAccessToken(token: token)) - - return request.validate().publishData() - } -} diff --git a/Siksha/Data/Repository/Repository.swift b/Siksha/Data/Repository/Repository.swift index f96d2722..74d29ab2 100644 --- a/Siksha/Data/Repository/Repository.swift +++ b/Siksha/Data/Repository/Repository.swift @@ -13,7 +13,7 @@ protocol NetworkModuleProtocol { func requestWithNoContent(endpoint: SikshaAPI) -> AnyPublisher } -final class Repository: RepositoryProtocol { +final class Repository { private let networkModule: NetworkModuleProtocol init(networkModule: NetworkModuleProtocol) { self.networkModule = networkModule @@ -112,37 +112,3 @@ extension Repository: CommunityRepositoryProtocol { } } - - -extension Repository: LegacyUserRepositoryProtocol { - func loadUserInfo() -> AnyPublisher { - let endpoint = SikshaAPI.getUserInfo - return self.networkModule.request(endpoint: endpoint) - } - func submitVOC(comment: String, platform: String) -> AnyPublisher { - let endpoint = SikshaAPI.submitVOC(comment: comment, platform: platform) - return self.networkModule.requestWithNoContent(endpoint: endpoint) - } - func deleteUser() -> AnyPublisher { - let endpoint = SikshaAPI.deleteUser - return self.networkModule.requestWithNoContent(endpoint: endpoint) - } - - func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher { - let endpoint = SikshaAPI.updateUserProfile(nickname: nickname, image: image, changeToDefaultImage: changeToDefaultImage) - return self.networkModule.request(endpoint: endpoint) - } -} - -extension Repository: LegacyAuthRepositoryProtocol { - func postUserDevice(fcmToken: String)-> AnyPublisher{ - let endpoint = SikshaAPI.postUserDevice(fcmToken: fcmToken) - return self.networkModule.requestWithNoContent(endpoint: endpoint) - - } - func deleteUserDevice(fcmToken: String)-> AnyPublisher{ - let endpoint = SikshaAPI.deleteUserDevice(fcmToken: fcmToken) - return self.networkModule.requestWithNoContent(endpoint: endpoint) - - } -} diff --git a/Siksha/Data/Repository/RepositoryProtocol.swift b/Siksha/Data/Repository/RepositoryProtocol.swift index 9047bd25..d2c8c816 100644 --- a/Siksha/Data/Repository/RepositoryProtocol.swift +++ b/Siksha/Data/Repository/RepositoryProtocol.swift @@ -8,10 +8,6 @@ import Foundation import Combine -protocol RepositoryProtocol: CommunityRepositoryProtocol, LegacyUserRepositoryProtocol, LegacyAuthRepositoryProtocol { - -} - protocol CommunityRepositoryProtocol { func loadBoardList() -> AnyPublisher<[Board], AppError> func submitPost(boardId:Int,title:String,content:String,images:[Data],anonymous:Bool) ->AnyPublisher @@ -31,17 +27,4 @@ protocol CommunityRepositoryProtocol { func unlikeComment(commentId: Int) -> AnyPublisher func reportPost(postId:Int,reason:String)->AnyPublisher func reportComment(commentId:Int,reason:String)->AnyPublisher - -} - -protocol LegacyUserRepositoryProtocol { - func loadUserInfo() -> AnyPublisher - func updateUserProfile(nickname: String?, image: Data?, changeToDefaultImage: Bool) -> AnyPublisher - func submitVOC(comment: String, platform: String) -> AnyPublisher - func deleteUser() -> AnyPublisher -} - -protocol LegacyAuthRepositoryProtocol { - func postUserDevice(fcmToken: String)-> AnyPublisher - func deleteUserDevice(fcmToken: String)-> AnyPublisher } diff --git a/Siksha/Library/Utils.swift b/Siksha/Library/Utils.swift deleted file mode 100644 index 14d7ff54..00000000 --- a/Siksha/Library/Utils.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Utils.swift -// Siksha -// -// Created by 박종석 on 2021/02/28. -// - -import Foundation - -class Utils { - static let shared = Utils() - - private init() {} - - // MARK: - JWT Decode - - func decode(jwtToken jwt: String) -> [String: Any] { - let segments = jwt.components(separatedBy: ".") - return decodeJWTPart(segments[1]) ?? [:] - } - - private func base64UrlDecode(_ value: String) -> Data? { - var base64 = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - - let length = Double(base64.lengthOfBytes(using: String.Encoding.utf8)) - let requiredLength = 4 * ceil(length / 4.0) - let paddingLength = requiredLength - length - if paddingLength > 0 { - let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) - base64 = base64 + padding - } - return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) - } - - private func decodeJWTPart(_ value: String) -> [String: Any]? { - guard let bodyData = base64UrlDecode(value), - let json = try? JSONSerialization.jsonObject(with: bodyData, options: []), let payload = json as? [String: Any] else { - return nil - } - - return payload - } - - func removeAllUserDefaults(){ - UserDefaults.standard.dictionaryRepresentation().keys.forEach{ - key in - UserDefaults.standard.removeObject(forKey: key) - } - } -} diff --git a/Siksha/System/DI/RepositoryProvider.swift b/Siksha/System/DI/RepositoryProvider.swift index 105d7046..ff127a77 100644 --- a/Siksha/System/DI/RepositoryProvider.swift +++ b/Siksha/System/DI/RepositoryProvider.swift @@ -5,24 +5,14 @@ // Created by 한상현 on 2023/09/11. // -import Foundation - final class RepositoryProvider { - private let repository: RepositoryProtocol + private let repository: CommunityRepositoryProtocol - init(repository: RepositoryProtocol) { + init(repository: CommunityRepositoryProtocol) { self.repository = repository } var communityRepository: CommunityRepositoryProtocol { return self.repository } - - var userRepository: LegacyUserRepositoryProtocol { - return self.repository - } - - var authRepository: LegacyAuthRepositoryProtocol { - return self.repository - } } From b0b20e7f1be05bb695132472b4f905766e9567f0 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Thu, 16 Jul 2026 23:13:39 +0900 Subject: [PATCH 13/14] test(auth): add session and authentication use case coverage --- Siksha.xcodeproj/project.pbxproj | 4 + SikshaTests/AuthRepositoryImplTests.swift | 251 ++++++++++++++ .../AuthSessionLocalDataSourceTests.swift | 128 +++++++ SikshaTests/AuthUseCaseTests.swift | 194 +++++++++++ .../ResolveInitialAuthStateUseCaseTests.swift | 328 ++++++++++++++++++ 5 files changed, 905 insertions(+) create mode 100644 SikshaTests/AuthRepositoryImplTests.swift create mode 100644 SikshaTests/AuthSessionLocalDataSourceTests.swift create mode 100644 SikshaTests/AuthUseCaseTests.swift create mode 100644 SikshaTests/ResolveInitialAuthStateUseCaseTests.swift diff --git a/Siksha.xcodeproj/project.pbxproj b/Siksha.xcodeproj/project.pbxproj index 9f06643a..95cf2653 100644 --- a/Siksha.xcodeproj/project.pbxproj +++ b/Siksha.xcodeproj/project.pbxproj @@ -81,11 +81,15 @@ AccountManageViewModelTests.swift, AccountUseCaseTests.swift, AppStateTests.swift, + AuthRepositoryImplTests.swift, + AuthSessionLocalDataSourceTests.swift, + AuthUseCaseTests.swift, DeviceTokenRemoteDataSourceTests.swift, DeviceTokenUseCaseTests.swift, ProfileEditViewModelTests.swift, RemoteImageLoaderTests.swift, RenewalSettingsViewModelTests.swift, + ResolveInitialAuthStateUseCaseTests.swift, RestaurantLocalDataSourceTests.swift, SikshaTests.swift, ); diff --git a/SikshaTests/AuthRepositoryImplTests.swift b/SikshaTests/AuthRepositoryImplTests.swift new file mode 100644 index 00000000..6b8f2f38 --- /dev/null +++ b/SikshaTests/AuthRepositoryImplTests.swift @@ -0,0 +1,251 @@ +// +// AuthRepositoryImplTests.swift +// SikshaTests +// +// Created by Codex on 7/16/26. +// + +import Foundation +import XCTest +@testable import Siksha + +final class AuthRepositoryImplTests: XCTestCase { + func testProviderLoginForwardsCredentialAndMapsSession() async throws { + let remote = AuthRemoteDataSourceSpy() + let local = AuthSessionLocalDataSourceSpy() + let expiration: TimeInterval = 2_000_000_000 + let accessToken = try makeJWT(expiration: expiration) + remote.loginResult = .success( + AuthTokenResponseDTO(accessToken: accessToken) + ) + let repository = makeRepository(remote: remote, local: local) + + let session = try await repository.login( + with: LoginCredential( + provider: .apple, + token: "identity-token", + appleUserIdentifier: "apple-user" + ) + ) + + XCTAssertEqual(remote.events, ["login:apple:identity-token"]) + XCTAssertEqual(session.accessToken, accessToken) + XCTAssertEqual(session.expiresAt?.timeIntervalSince1970, expiration) + XCTAssertEqual(session.appleUserIdentifier, "apple-user") + XCTAssertTrue(local.events.isEmpty) + } + + func testLoginForTestUsesTestEndpointAndReturnsNonAppleSession() async throws { + let remote = AuthRemoteDataSourceSpy() + let local = AuthSessionLocalDataSourceSpy() + remote.testLoginResult = .success( + AuthTokenResponseDTO(accessToken: "test-access-token") + ) + let repository = makeRepository(remote: remote, local: local) + + let session = try await repository.loginForTest() + + XCTAssertEqual(remote.events, ["testLogin"]) + XCTAssertEqual(session.accessToken, "test-access-token") + XCTAssertNil(session.expiresAt) + XCTAssertNil(session.appleUserIdentifier) + XCTAssertTrue(local.events.isEmpty) + } + + func testRefreshForwardsAccessTokenAndPreservesStoredAppleIdentifier() async throws { + let expiration: TimeInterval = 2_100_000_000 + let remote = AuthRemoteDataSourceSpy() + let refreshedAccessToken = try makeJWT(expiration: expiration) + remote.refreshResult = .success( + AuthTokenResponseDTO(accessToken: refreshedAccessToken) + ) + let local = AuthSessionLocalDataSourceSpy( + storedSession: AuthSession( + accessToken: "old-access-token", + expiresAt: nil, + appleUserIdentifier: "apple-user" + ) + ) + let repository = makeRepository(remote: remote, local: local) + + let session = try await repository.refreshAccessToken("old-access-token") + + XCTAssertEqual(remote.events, ["refresh:old-access-token"]) + XCTAssertEqual(local.events, ["load"]) + XCTAssertEqual(session.accessToken, refreshedAccessToken) + XCTAssertEqual(session.expiresAt?.timeIntervalSince1970, expiration) + XCTAssertEqual(session.appleUserIdentifier, "apple-user") + } + + func testMalformedJWTHasNoExpiration() async throws { + let remote = AuthRemoteDataSourceSpy() + remote.testLoginResult = .success( + AuthTokenResponseDTO(accessToken: "malformed-token") + ) + let repository = makeRepository( + remote: remote, + local: AuthSessionLocalDataSourceSpy() + ) + + let session = try await repository.loginForTest() + + XCTAssertNil(session.expiresAt) + } + + func testJWTWithoutExpirationHasNoExpiration() async throws { + let remote = AuthRemoteDataSourceSpy() + remote.testLoginResult = .success( + AuthTokenResponseDTO(accessToken: try makeJWT(expiration: nil)) + ) + let repository = makeRepository( + remote: remote, + local: AuthSessionLocalDataSourceSpy() + ) + + let session = try await repository.loginForTest() + + XCTAssertNil(session.expiresAt) + } + + func testProviderLoginErrorIsPropagatedWithoutLocalMutation() async { + let remote = AuthRemoteDataSourceSpy() + remote.loginResult = .failure(AuthRepositoryTestError.remoteFailed) + let local = AuthSessionLocalDataSourceSpy() + let repository = makeRepository(remote: remote, local: local) + + do { + _ = try await repository.login( + with: LoginCredential(provider: .google, token: "id-token") + ) + XCTFail("Expected remote error") + } catch let error as AuthRepositoryTestError { + XCTAssertEqual(error, .remoteFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(remote.events, ["login:google:id-token"]) + XCTAssertTrue(local.events.isEmpty) + } + + func testRefreshErrorIsPropagatedWithoutLocalMutation() async { + let remote = AuthRemoteDataSourceSpy() + remote.refreshResult = .failure(AuthRepositoryTestError.remoteFailed) + let local = AuthSessionLocalDataSourceSpy() + let repository = makeRepository(remote: remote, local: local) + + do { + _ = try await repository.refreshAccessToken("old-access-token") + XCTFail("Expected remote error") + } catch let error as AuthRepositoryTestError { + XCTAssertEqual(error, .remoteFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(remote.events, ["refresh:old-access-token"]) + XCTAssertTrue(local.events.isEmpty) + } + + func testSessionPersistenceMethodsDelegateToLocalDataSource() throws { + let remote = AuthRemoteDataSourceSpy() + let local = AuthSessionLocalDataSourceSpy( + storedSession: AuthSession( + accessToken: "stored-token", + expiresAt: nil + ) + ) + let repository = makeRepository(remote: remote, local: local) + + let loadedSession = try XCTUnwrap(repository.loadSession()) + repository.saveSession( + AuthSession(accessToken: "new-token", expiresAt: nil) + ) + repository.clearSession() + + XCTAssertEqual(loadedSession.accessToken, "stored-token") + XCTAssertEqual(local.events, ["load", "save:new-token", "clear"]) + XCTAssertNil(local.storedSession) + XCTAssertTrue(remote.events.isEmpty) + } + + private func makeRepository( + remote: AuthRemoteDataSource, + local: AuthSessionLocalDataSource + ) -> AuthRepositoryImpl { + AuthRepositoryImpl(remote: remote, local: local) + } + + private func makeJWT(expiration: TimeInterval?) throws -> String { + var payload: [String: Any] = [:] + if let expiration { + payload["exp"] = expiration + } + + let payloadData = try JSONSerialization.data(withJSONObject: payload) + let payloadSegment = payloadData + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + return "header.\(payloadSegment).signature" + } +} + +private enum AuthRepositoryTestError: Error, Equatable { + case remoteFailed + case unconfigured +} + +private final class AuthRemoteDataSourceSpy: AuthRemoteDataSource { + var loginResult: Result = .failure( + AuthRepositoryTestError.unconfigured + ) + var testLoginResult: Result = .failure( + AuthRepositoryTestError.unconfigured + ) + var refreshResult: Result = .failure( + AuthRepositoryTestError.unconfigured + ) + private(set) var events: [String] = [] + + func login(provider: LoginProvider, token: String) async throws -> AuthTokenResponseDTO { + events.append("login:\(provider.rawValue):\(token)") + return try loginResult.get() + } + + func loginForTest() async throws -> AuthTokenResponseDTO { + events.append("testLogin") + return try testLoginResult.get() + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthTokenResponseDTO { + events.append("refresh:\(accessToken)") + return try refreshResult.get() + } +} + +private final class AuthSessionLocalDataSourceSpy: AuthSessionLocalDataSource { + var storedSession: AuthSession? + private(set) var events: [String] = [] + + init(storedSession: AuthSession? = nil) { + self.storedSession = storedSession + } + + func loadSession() -> AuthSession? { + events.append("load") + return storedSession + } + + func saveSession(_ session: AuthSession) { + events.append("save:\(session.accessToken)") + storedSession = session + } + + func clearSession() { + events.append("clear") + storedSession = nil + } +} diff --git a/SikshaTests/AuthSessionLocalDataSourceTests.swift b/SikshaTests/AuthSessionLocalDataSourceTests.swift new file mode 100644 index 00000000..60d246b4 --- /dev/null +++ b/SikshaTests/AuthSessionLocalDataSourceTests.swift @@ -0,0 +1,128 @@ +// +// AuthSessionLocalDataSourceTests.swift +// SikshaTests +// +// Created by Codex on 7/16/26. +// + +import Foundation +import XCTest +@testable import Siksha + +final class AuthSessionLocalDataSourceTests: XCTestCase { + private enum Key { + static let accessToken = "accessToken" + static let tokenExpDate = "tokenExpDate" + static let appleUserIdentifier = "appleUserIdentifier" + static let signedInWithApple = "signedInWithApple" + } + + private var suiteName: String! + private var userDefaults: UserDefaults! + private var dataSource: AuthSessionLocalDataSourceImpl! + + override func setUp() { + super.setUp() + suiteName = "AuthSessionLocalDataSourceTests.\(UUID().uuidString)" + userDefaults = UserDefaults(suiteName: suiteName) + userDefaults.removePersistentDomain(forName: suiteName) + dataSource = AuthSessionLocalDataSourceImpl(userDefaults: userDefaults) + } + + override func tearDown() { + userDefaults.removePersistentDomain(forName: suiteName) + dataSource = nil + userDefaults = nil + suiteName = nil + super.tearDown() + } + + func testLoadSessionWithoutAccessTokenReturnsNil() { + userDefaults.set(123, forKey: Key.tokenExpDate) + userDefaults.set("apple-user", forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + + XCTAssertNil(dataSource.loadSession()) + } + + func testLoadSessionRestoresExistingStorageKeys() throws { + let expiration = Date(timeIntervalSince1970: 2_000_000_000) + userDefaults.set("access-token", forKey: Key.accessToken) + userDefaults.set(expiration.timeIntervalSince1970, forKey: Key.tokenExpDate) + userDefaults.set("apple-user", forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + + let session = try XCTUnwrap(dataSource.loadSession()) + + XCTAssertEqual(session.accessToken, "access-token") + XCTAssertEqual(session.expiresAt, expiration) + XCTAssertEqual(session.appleUserIdentifier, "apple-user") + } + + func testLoadSessionIgnoresStaleAppleIdentifierWhenFlagIsFalse() throws { + userDefaults.set("access-token", forKey: Key.accessToken) + userDefaults.set("stale-apple-user", forKey: Key.appleUserIdentifier) + userDefaults.set(false, forKey: Key.signedInWithApple) + + let session = try XCTUnwrap(dataSource.loadSession()) + + XCTAssertNil(session.appleUserIdentifier) + } + + func testSaveAppleSessionWritesExistingStorageKeys() { + let expiration = Date(timeIntervalSince1970: 2_000_000_000) + + dataSource.saveSession( + AuthSession( + accessToken: "access-token", + expiresAt: expiration, + appleUserIdentifier: "apple-user" + ) + ) + + XCTAssertEqual(userDefaults.string(forKey: Key.accessToken), "access-token") + XCTAssertEqual( + userDefaults.double(forKey: Key.tokenExpDate), + expiration.timeIntervalSince1970 + ) + XCTAssertEqual( + userDefaults.string(forKey: Key.appleUserIdentifier), + "apple-user" + ) + XCTAssertTrue(userDefaults.bool(forKey: Key.signedInWithApple)) + } + + func testSaveNonAppleSessionClearsStaleExpirationAndAppleState() { + userDefaults.set(1_900_000_000, forKey: Key.tokenExpDate) + userDefaults.set("stale-apple-user", forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + + dataSource.saveSession( + AuthSession( + accessToken: "new-access-token", + expiresAt: nil + ) + ) + + XCTAssertEqual(userDefaults.string(forKey: Key.accessToken), "new-access-token") + XCTAssertNil(userDefaults.object(forKey: Key.tokenExpDate)) + XCTAssertNil(userDefaults.object(forKey: Key.appleUserIdentifier)) + XCTAssertFalse(userDefaults.bool(forKey: Key.signedInWithApple)) + } + + func testClearSessionRemovesOnlyAuthKeys() { + userDefaults.set("access-token", forKey: Key.accessToken) + userDefaults.set(2_000_000_000, forKey: Key.tokenExpDate) + userDefaults.set("apple-user", forKey: Key.appleUserIdentifier) + userDefaults.set(true, forKey: Key.signedInWithApple) + userDefaults.set("keep-me", forKey: "unrelatedKey") + + dataSource.clearSession() + + XCTAssertNil(userDefaults.object(forKey: Key.accessToken)) + XCTAssertNil(userDefaults.object(forKey: Key.tokenExpDate)) + XCTAssertNil(userDefaults.object(forKey: Key.appleUserIdentifier)) + XCTAssertNil(userDefaults.object(forKey: Key.signedInWithApple)) + XCTAssertEqual(userDefaults.string(forKey: "unrelatedKey"), "keep-me") + } +} diff --git a/SikshaTests/AuthUseCaseTests.swift b/SikshaTests/AuthUseCaseTests.swift new file mode 100644 index 00000000..ccbc1e86 --- /dev/null +++ b/SikshaTests/AuthUseCaseTests.swift @@ -0,0 +1,194 @@ +// +// AuthUseCaseTests.swift +// SikshaTests +// +// Created by Codex on 7/16/26. +// + +import XCTest +@testable import Siksha + +final class AuthUseCaseTests: XCTestCase { + func testProviderLoginSavesReturnedSessionAfterLogin() async throws { + let session = makeSession(accessToken: "new-access-token") + let repository = AuthUseCaseRepositorySpy() + repository.loginResult = .success(session) + let useCase = DefaultLoginUseCase(repository: repository) + + let returnedSession = try await useCase.login( + with: LoginCredential(provider: .kakao, token: "provider-token") + ) + + XCTAssertEqual(returnedSession.accessToken, "new-access-token") + XCTAssertEqual( + repository.events, + ["login:kakao:provider-token", "save:new-access-token"] + ) + XCTAssertEqual(repository.savedSessions.count, 1) + } + + func testProviderLoginFailureDoesNotSaveOrClearSession() async { + let repository = AuthUseCaseRepositorySpy() + repository.loginResult = .failure(AuthUseCaseTestError.loginFailed) + let useCase = DefaultLoginUseCase(repository: repository) + + do { + _ = try await useCase.login( + with: LoginCredential(provider: .google, token: "provider-token") + ) + XCTFail("Expected login error") + } catch let error as AuthUseCaseTestError { + XCTAssertEqual(error, .loginFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(repository.events, ["login:google:provider-token"]) + XCTAssertTrue(repository.savedSessions.isEmpty) + XCTAssertEqual(repository.clearCount, 0) + } + + func testLoginForTestSavesReturnedSessionAfterLogin() async throws { + let repository = AuthUseCaseRepositorySpy() + repository.testLoginResult = .success( + makeSession(accessToken: "test-access-token") + ) + let useCase = DefaultLoginUseCase(repository: repository) + + let returnedSession = try await useCase.loginForTest() + + XCTAssertEqual(returnedSession.accessToken, "test-access-token") + XCTAssertEqual( + repository.events, + ["testLogin", "save:test-access-token"] + ) + XCTAssertEqual(repository.savedSessions.count, 1) + } + + func testLoginForTestFailureDoesNotSaveOrClearSession() async { + let repository = AuthUseCaseRepositorySpy() + repository.testLoginResult = .failure(AuthUseCaseTestError.loginFailed) + let useCase = DefaultLoginUseCase(repository: repository) + + do { + _ = try await useCase.loginForTest() + XCTFail("Expected login error") + } catch let error as AuthUseCaseTestError { + XCTAssertEqual(error, .loginFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(repository.events, ["testLogin"]) + XCTAssertTrue(repository.savedSessions.isEmpty) + XCTAssertEqual(repository.clearCount, 0) + } + + func testRefreshWithoutStoredSessionReturnsNilWithoutRemoteWork() async throws { + let repository = AuthUseCaseRepositorySpy() + repository.sessionToLoad = nil + let useCase = DefaultRefreshAccessTokenUseCase(repository: repository) + + let session = try await useCase.execute() + + XCTAssertNil(session) + XCTAssertEqual(repository.events, ["load"]) + XCTAssertTrue(repository.savedSessions.isEmpty) + } + + func testRefreshSavesReturnedSessionAfterRefresh() async throws { + let repository = AuthUseCaseRepositorySpy() + repository.sessionToLoad = makeSession(accessToken: "old-access-token") + repository.refreshResult = .success( + makeSession(accessToken: "new-access-token") + ) + let useCase = DefaultRefreshAccessTokenUseCase(repository: repository) + + let session = try await useCase.execute() + + XCTAssertEqual(session?.accessToken, "new-access-token") + XCTAssertEqual( + repository.events, + ["load", "refresh:old-access-token", "save:new-access-token"] + ) + XCTAssertEqual(repository.savedSessions.count, 1) + } + + func testRefreshFailureDoesNotSaveOrClearSession() async { + let repository = AuthUseCaseRepositorySpy() + repository.sessionToLoad = makeSession(accessToken: "old-access-token") + repository.refreshResult = .failure(AuthUseCaseTestError.refreshFailed) + let useCase = DefaultRefreshAccessTokenUseCase(repository: repository) + + do { + _ = try await useCase.execute() + XCTFail("Expected refresh error") + } catch let error as AuthUseCaseTestError { + XCTAssertEqual(error, .refreshFailed) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(repository.events, ["load", "refresh:old-access-token"]) + XCTAssertTrue(repository.savedSessions.isEmpty) + XCTAssertEqual(repository.clearCount, 0) + } + + private func makeSession(accessToken: String) -> AuthSession { + AuthSession(accessToken: accessToken, expiresAt: nil) + } +} + +private enum AuthUseCaseTestError: Error, Equatable { + case loginFailed + case refreshFailed + case unconfigured +} + +private final class AuthUseCaseRepositorySpy: AuthRepositoryProtocol { + var loginResult: Result = .failure( + AuthUseCaseTestError.unconfigured + ) + var testLoginResult: Result = .failure( + AuthUseCaseTestError.unconfigured + ) + var refreshResult: Result = .failure( + AuthUseCaseTestError.unconfigured + ) + var sessionToLoad: AuthSession? + private(set) var savedSessions: [AuthSession] = [] + private(set) var clearCount = 0 + private(set) var events: [String] = [] + + func login(with credential: LoginCredential) async throws -> AuthSession { + events.append("login:\(credential.provider.rawValue):\(credential.token)") + return try loginResult.get() + } + + func loginForTest() async throws -> AuthSession { + events.append("testLogin") + return try testLoginResult.get() + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + events.append("refresh:\(accessToken)") + return try refreshResult.get() + } + + func loadSession() -> AuthSession? { + events.append("load") + return sessionToLoad + } + + func saveSession(_ session: AuthSession) { + events.append("save:\(session.accessToken)") + savedSessions.append(session) + sessionToLoad = session + } + + func clearSession() { + events.append("clear") + clearCount += 1 + sessionToLoad = nil + } +} diff --git a/SikshaTests/ResolveInitialAuthStateUseCaseTests.swift b/SikshaTests/ResolveInitialAuthStateUseCaseTests.swift new file mode 100644 index 00000000..ed1f5bd2 --- /dev/null +++ b/SikshaTests/ResolveInitialAuthStateUseCaseTests.swift @@ -0,0 +1,328 @@ +// +// ResolveInitialAuthStateUseCaseTests.swift +// SikshaTests +// +// Created by Codex on 7/16/26. +// + +import Foundation +import XCTest +@testable import Siksha + +final class ResolveInitialAuthStateUseCaseTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 2_000_000_000) + private let refreshWindow: TimeInterval = 100 + + func testNoSessionRequiresLoginWithoutClearingOrExternalWork() async { + let context = makeContext(session: nil) + + let state = await context.useCase.execute() + + assertRequiresLogin(state) + XCTAssertEqual(context.authRepository.loadCount, 1) + XCTAssertEqual(context.authRepository.clearCount, 0) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + XCTAssertTrue(context.appleRepository.requestedIdentifiers.isEmpty) + } + + func testSessionExpiringNowIsClearedBeforeAppleOrRefreshWork() async { + let context = makeContext( + session: makeSession( + expiresAt: now, + appleUserIdentifier: "apple-user" + ) + ) + + let state = await context.useCase.execute() + + assertRequiresLogin(state) + XCTAssertEqual(context.authRepository.clearCount, 1) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + XCTAssertTrue(context.appleRepository.requestedIdentifiers.isEmpty) + } + + func testSessionWithoutExpirationIsAuthenticatedWithoutRefresh() async { + let session = makeSession(expiresAt: nil) + let context = makeContext(session: session) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: session.accessToken) + XCTAssertEqual(context.authRepository.clearCount, 0) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + XCTAssertTrue(context.appleRepository.requestedIdentifiers.isEmpty) + } + + func testSessionOutsideRefreshWindowIsAuthenticatedWithoutRefresh() async { + let session = makeSession( + expiresAt: now.addingTimeInterval(refreshWindow + 1) + ) + let context = makeContext(session: session) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: session.accessToken) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + XCTAssertEqual(context.authRepository.clearCount, 0) + } + + func testSessionAtRefreshWindowBoundaryIsNotRefreshed() async { + let session = makeSession( + expiresAt: now.addingTimeInterval(refreshWindow) + ) + let context = makeContext(session: session) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: session.accessToken) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + XCTAssertEqual(context.authRepository.clearCount, 0) + } + + func testRevokedAppleCredentialClearsSessionAndSkipsRefresh() async { + let context = makeContext( + session: makeSession( + expiresAt: now.addingTimeInterval(50), + appleUserIdentifier: "apple-user" + ), + appleStatus: .revoked + ) + + let state = await context.useCase.execute() + + assertRequiresLogin(state) + XCTAssertEqual(context.appleRepository.requestedIdentifiers, ["apple-user"]) + XCTAssertEqual(context.authRepository.clearCount, 1) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + } + + func testNotFoundAppleCredentialClearsSessionAndSkipsRefresh() async { + let context = makeContext( + session: makeSession( + expiresAt: now.addingTimeInterval(50), + appleUserIdentifier: "apple-user" + ), + appleStatus: .notFound + ) + + let state = await context.useCase.execute() + + assertRequiresLogin(state) + XCTAssertEqual(context.appleRepository.requestedIdentifiers, ["apple-user"]) + XCTAssertEqual(context.authRepository.clearCount, 1) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + } + + func testAuthorizedAppleCredentialKeepsSession() async { + let session = makeSession( + expiresAt: nil, + appleUserIdentifier: "apple-user" + ) + let context = makeContext(session: session, appleStatus: .authorized) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: session.accessToken) + XCTAssertEqual(context.appleRepository.requestedIdentifiers, ["apple-user"]) + XCTAssertEqual(context.authRepository.clearCount, 0) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + } + + func testUnknownAppleCredentialKeepsSession() async { + let session = makeSession( + expiresAt: nil, + appleUserIdentifier: "apple-user" + ) + let context = makeContext(session: session, appleStatus: .unknown) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: session.accessToken) + XCTAssertEqual(context.appleRepository.requestedIdentifiers, ["apple-user"]) + XCTAssertEqual(context.authRepository.clearCount, 0) + XCTAssertEqual(context.refreshUseCase.executionCount, 0) + } + + func testRefreshSuccessAuthenticatesRefreshedSession() async { + let refreshedSession = AuthSession( + accessToken: "refreshed-access-token", + expiresAt: now.addingTimeInterval(1_000) + ) + let context = makeContext( + session: makeSession(expiresAt: now.addingTimeInterval(50)), + refreshResult: .success(refreshedSession) + ) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: "refreshed-access-token") + XCTAssertEqual(context.refreshUseCase.executionCount, 1) + XCTAssertEqual(context.authRepository.clearCount, 0) + } + + func testNilRefreshResultKeepsOriginalSession() async { + let originalSession = makeSession( + accessToken: "original-access-token", + expiresAt: now.addingTimeInterval(50) + ) + let context = makeContext( + session: originalSession, + refreshResult: .success(nil) + ) + + let state = await context.useCase.execute() + + assertAuthenticated(state, accessToken: "original-access-token") + XCTAssertEqual(context.refreshUseCase.executionCount, 1) + XCTAssertEqual(context.authRepository.clearCount, 0) + } + + func testRefreshFailureClearsSessionAndRequiresLogin() async { + let context = makeContext( + session: makeSession(expiresAt: now.addingTimeInterval(50)), + refreshResult: .failure(InitialAuthTestError.refreshFailed) + ) + + let state = await context.useCase.execute() + + assertRequiresLogin(state) + XCTAssertEqual(context.refreshUseCase.executionCount, 1) + XCTAssertEqual(context.authRepository.clearCount, 1) + } + + private func makeContext( + session: AuthSession?, + appleStatus: AppleCredentialStatus = .authorized, + refreshResult: Result = .success(nil) + ) -> InitialAuthTestContext { + let authRepository = InitialAuthRepositorySpy(session: session) + let refreshUseCase = InitialRefreshAccessTokenUseCaseStub(result: refreshResult) + let appleRepository = InitialAppleCredentialRepositorySpy(status: appleStatus) + let currentDate = now + let useCase = DefaultResolveInitialAuthStateUseCase( + authRepository: authRepository, + refreshAccessTokenUseCase: refreshUseCase, + appleCredentialRepository: appleRepository, + refreshWindow: refreshWindow, + now: { currentDate } + ) + + return InitialAuthTestContext( + useCase: useCase, + authRepository: authRepository, + refreshUseCase: refreshUseCase, + appleRepository: appleRepository + ) + } + + private func makeSession( + accessToken: String = "access-token", + expiresAt: Date?, + appleUserIdentifier: String? = nil + ) -> AuthSession { + AuthSession( + accessToken: accessToken, + expiresAt: expiresAt, + appleUserIdentifier: appleUserIdentifier + ) + } + + private func assertRequiresLogin( + _ state: AuthState, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .requiresLogin = state else { + return XCTFail("Expected requiresLogin", file: file, line: line) + } + } + + private func assertAuthenticated( + _ state: AuthState, + accessToken: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case let .authenticated(session) = state else { + return XCTFail("Expected authenticated", file: file, line: line) + } + + XCTAssertEqual(session.accessToken, accessToken, file: file, line: line) + } +} + +private struct InitialAuthTestContext { + let useCase: DefaultResolveInitialAuthStateUseCase + let authRepository: InitialAuthRepositorySpy + let refreshUseCase: InitialRefreshAccessTokenUseCaseStub + let appleRepository: InitialAppleCredentialRepositorySpy +} + +private enum InitialAuthTestError: Error { + case refreshFailed + case unexpected +} + +private final class InitialAuthRepositorySpy: AuthRepositoryProtocol { + private var session: AuthSession? + private(set) var loadCount = 0 + private(set) var clearCount = 0 + + init(session: AuthSession?) { + self.session = session + } + + func login(with credential: LoginCredential) async throws -> AuthSession { + throw InitialAuthTestError.unexpected + } + + func loginForTest() async throws -> AuthSession { + throw InitialAuthTestError.unexpected + } + + func refreshAccessToken(_ accessToken: String) async throws -> AuthSession { + throw InitialAuthTestError.unexpected + } + + func loadSession() -> AuthSession? { + loadCount += 1 + return session + } + + func saveSession(_ session: AuthSession) { + self.session = session + } + + func clearSession() { + clearCount += 1 + session = nil + } +} + +private final class InitialRefreshAccessTokenUseCaseStub: RefreshAccessTokenUseCase { + private let result: Result + private(set) var executionCount = 0 + + init(result: Result) { + self.result = result + } + + func execute() async throws -> AuthSession? { + executionCount += 1 + return try result.get() + } +} + +private final class InitialAppleCredentialRepositorySpy: AppleCredentialRepositoryProtocol { + private let status: AppleCredentialStatus + private(set) var requestedIdentifiers: [String] = [] + + init(status: AppleCredentialStatus) { + self.status = status + } + + func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus { + requestedIdentifiers.append(userIdentifier) + return status + } +} From 710d93027338f376c506e67936e2c689c28954c6 Mon Sep 17 00:00:00 2001 From: h9kwon Date: Fri, 17 Jul 2026 02:26:03 +0900 Subject: [PATCH 14/14] chore(signing): specify match branch and team --- fastlane/Matchfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 7342e4bf..b68c566e 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -1,5 +1,8 @@ git_url("git@github.com:wafflestudio/siksha-ios-private.git") +git_branch("master") storage_mode("git") -username("apple@wafflestudio.com") \ No newline at end of file +username("apple@wafflestudio.com") +team_id("K9883YB4VR") +