diff --git a/Rephoto_iOS/App/ContentView.swift b/Rephoto_iOS/App/ContentView.swift index 08523ac..61f2a4d 100644 --- a/Rephoto_iOS/App/ContentView.swift +++ b/Rephoto_iOS/App/ContentView.swift @@ -6,27 +6,25 @@ // import SwiftUI +import Factory struct ContentView: View { - @State private var loginVM: LoginViewModel - - init(userProvider: UserUseCaseProviderProtocol) { - self._loginVM = State(initialValue: LoginViewModel(provider: userProvider)) - } + @Injected(\.sessionStore) private var session var body: some View { Group { - if loginVM.isLoggedIn { + if session.isLoggedIn { RephotoTabView() } else { - LoginView(loginVM: loginVM) + LoginView(session: session) } } + .task { await session.restore() } } } #if DEBUG #Preview { - ContentView(userProvider: MockUserUseCaseProvider()) + ContentView() } #endif diff --git a/Rephoto_iOS/App/Rephoto_iOSApp.swift b/Rephoto_iOS/App/Rephoto_iOSApp.swift index 313f322..33a8684 100644 --- a/Rephoto_iOS/App/Rephoto_iOSApp.swift +++ b/Rephoto_iOS/App/Rephoto_iOSApp.swift @@ -6,15 +6,12 @@ // import SwiftUI -import Factory @main struct Rephoto_iOSApp: App { - @Injected(\.userUseCaseProvider) private var userProvider - var body: some Scene { WindowGroup { - ContentView(userProvider: userProvider) + ContentView() } } } diff --git a/Rephoto_iOS/Core/DIContainer/AppContainer.swift b/Rephoto_iOS/Core/DIContainer/AppContainer.swift index 9c4016f..d08533e 100644 --- a/Rephoto_iOS/Core/DIContainer/AppContainer.swift +++ b/Rephoto_iOS/Core/DIContainer/AppContainer.swift @@ -80,6 +80,17 @@ extension Container: @retroactive AutoRegistering { } } + // MARK: - Session + + /// 앱 전역 세션. @MainActor 타입이므로 메인 스레드 해석을 전제로 한다 (SwiftUI 뷰 초기화 시점). + var sessionStore: Factory { + self { + MainActor.assumeIsolated { + SessionStore(provider: self.userUseCaseProvider.resolve()) + } + }.singleton + } + // MARK: - AutoRegistering public func autoRegister() { diff --git a/Rephoto_iOS/Features/RephotoTabView.swift b/Rephoto_iOS/Features/RephotoTabView.swift index 6b90a6a..40789eb 100644 --- a/Rephoto_iOS/Features/RephotoTabView.swift +++ b/Rephoto_iOS/Features/RephotoTabView.swift @@ -10,6 +10,7 @@ import PhotosUI import Factory struct RephotoTabView: View { + @Injected(\.sessionStore) private var session @Injected(\.homeUseCaseProvider) private var homeProvider @Injected(\.searchUseCaseProvider) private var searchProvider @@ -24,6 +25,8 @@ struct RephotoTabView: View { } .tint(.green) .tabBarMinimizeBehavior(.onScrollDown) + // 하위 탭(프로필/로그아웃 UI 등)이 @Environment(SessionStore.self)로 세션에 접근 + .environment(session) } } diff --git a/Rephoto_iOS/Features/User/Presentation/Session/SessionStore.swift b/Rephoto_iOS/Features/User/Presentation/Session/SessionStore.swift new file mode 100644 index 0000000..eaad08a --- /dev/null +++ b/Rephoto_iOS/Features/User/Presentation/Session/SessionStore.swift @@ -0,0 +1,62 @@ +// +// SessionStore.swift +// Rephoto_iOS +// +// Created by 김도연 on 7/2/26. +// + +import SwiftUI + +/// 앱 전역 인증/세션 상태를 소유한다. +/// 인증 동작(로그인/로그아웃/복원)을 담당하며 실패는 throw로 알린다. +/// 로딩/에러 같은 화면 표현 상태는 각 화면의 ViewModel이 가진다. +@Observable +@MainActor +final class SessionStore { + private let provider: UserUseCaseProviderProtocol + + private(set) var isLoggedIn = false + private(set) var userInfo: UserInfo? + private(set) var name: String = "리포토" + + init(provider: UserUseCaseProviderProtocol) { + self.provider = provider + + provider.setOnRefreshFailed { [weak self] in + Task { @MainActor in + self?.forceLogout() + } + } + } + + /// 앱 시작 시 저장된 토큰으로 세션 복원 (자동 로그인). + func restore() async { + guard await provider.hasTokens() else { return } + isLoggedIn = true + await refreshUser() + } + + /// 자격 증명으로 로그인. 실패 시 throw. + func login(id: String, password: String) async throws { + try await provider.login().execute(loginId: id, password: password) + isLoggedIn = true + await refreshUser() + } + + func logout() async { + try? await provider.logout().execute() + forceLogout() + } + + /// 토큰 리프레시 실패 등으로 인한 강제 로그아웃 (로컬 상태만 정리). + func forceLogout() { + userInfo = nil + isLoggedIn = false + } + + private func refreshUser() async { + guard let info = try? await provider.fetchUser().execute() else { return } + userInfo = info + name = info.name + } +} diff --git a/Rephoto_iOS/Features/User/Presentation/ViewModels/LoginViewModel.swift b/Rephoto_iOS/Features/User/Presentation/ViewModels/LoginViewModel.swift index 6d6af2b..d86c4c6 100644 --- a/Rephoto_iOS/Features/User/Presentation/ViewModels/LoginViewModel.swift +++ b/Rephoto_iOS/Features/User/Presentation/ViewModels/LoginViewModel.swift @@ -10,8 +10,8 @@ import SwiftUI @Observable @MainActor final class LoginViewModel { - let provider: UserUseCaseProviderProtocol - + private let session: SessionStore + var loginId: String = "" var password: String = "" private(set) var isLoading = false @@ -20,25 +20,9 @@ final class LoginViewModel { get { errorMessage != nil } set { if !newValue { errorMessage = nil } } } - private(set) var isLoggedIn = false - private(set) var userInfo: UserInfo? - private(set) var name: String = "리포토" - - init(provider: UserUseCaseProviderProtocol) { - self.provider = provider - provider.setOnRefreshFailed { [weak self] in - Task { @MainActor in - self?.forceLogout() - } - } - } - - func onAppear() async { - if await provider.hasTokens() { - isLoggedIn = true - await fetchUser() - } + init(session: SessionStore) { + self.session = session } func login() async { @@ -51,47 +35,11 @@ final class LoginViewModel { errorMessage = nil do { - try await provider.login().execute(loginId: loginId, password: password) - isLoggedIn = true - await fetchUser() + try await session.login(id: loginId, password: password) } catch { errorMessage = "로그인 실패: \(error.localizedDescription)" } isLoading = false } - - func fetchUser() async { - isLoading = true - errorMessage = nil - - do { - let info = try await provider.fetchUser().execute() - userInfo = info - name = info.name - } catch { - errorMessage = "내 정보 조회 실패: \(error.localizedDescription)" - } - - isLoading = false - } - - func logout() async { - isLoading = true - errorMessage = nil - - do { - try await provider.logout().execute() - } catch { - errorMessage = "로그아웃 실패: \(error.localizedDescription)" - } - - forceLogout() - isLoading = false - } - - func forceLogout() { - userInfo = nil - isLoggedIn = false - } } diff --git a/Rephoto_iOS/Features/User/Presentation/Views/LoginView.swift b/Rephoto_iOS/Features/User/Presentation/Views/LoginView.swift index b0f78d5..06fe80a 100644 --- a/Rephoto_iOS/Features/User/Presentation/Views/LoginView.swift +++ b/Rephoto_iOS/Features/User/Presentation/Views/LoginView.swift @@ -8,7 +8,11 @@ import SwiftUI struct LoginView: View { - @Bindable var loginVM: LoginViewModel + @State private var loginVM: LoginViewModel + + init(session: SessionStore) { + self._loginVM = State(initialValue: LoginViewModel(session: session)) + } var body: some View { ZStack { @@ -26,7 +30,6 @@ struct LoginView: View { Text(message) } } - .task { await loginVM.onAppear() } } // MARK: - Title @@ -91,6 +94,6 @@ struct LoginView: View { #if DEBUG #Preview("Login") { - LoginView(loginVM: LoginViewModel(provider: MockUserUseCaseProvider())) + LoginView(session: SessionStore(provider: MockUserUseCaseProvider())) } #endif diff --git a/Rephoto_iOSTests/LoginViewModelTests.swift b/Rephoto_iOSTests/LoginViewModelTests.swift new file mode 100644 index 0000000..452add4 --- /dev/null +++ b/Rephoto_iOSTests/LoginViewModelTests.swift @@ -0,0 +1,77 @@ +// +// LoginViewModelTests.swift +// Rephoto_iOSTests +// +// 로그인 화면 ViewModel의 표현 상태 검증. +// - 입력 검증(빈 필드) 시 에러 메시지, 세션 호출 없음 +// - 로그인 성공/실패 시 isLoading/errorMessage 상태 전이 +// + +import XCTest +@testable import Rephoto_iOS + +@MainActor +final class LoginViewModelTests: XCTestCase { + + private var provider: MockUserUseCaseProvider! + private var session: SessionStore! + private var sut: LoginViewModel! + + override func setUp() async throws { + try await super.setUp() + provider = MockUserUseCaseProvider() + session = SessionStore(provider: provider) + sut = LoginViewModel(session: session) + } + + override func tearDown() async throws { + sut = nil + session = nil + provider = nil + try await super.tearDown() + } + + func test_login_emptyFields_setsValidationError_withoutCallingSession() async { + sut.loginId = "" + sut.password = "pw" + + await sut.login() + + XCTAssertEqual(sut.errorMessage, "아이디와 비밀번호를 입력해주세요.") + XCTAssertEqual(provider.loginCallCount, 0) + XCTAssertFalse(session.isLoggedIn) + } + + func test_login_success_logsInWithoutError() async { + provider.fetchUserResult = .success(UserInfo(userId: 1, loginId: 100, name: "도연")) + sut.loginId = "dodle" + sut.password = "pw" + + await sut.login() + + XCTAssertNil(sut.errorMessage) + XCTAssertFalse(sut.isLoading) + XCTAssertTrue(session.isLoggedIn) + } + + func test_login_failure_setsErrorMessage_andEndsLoading() async { + provider.loginResult = .failure(MockUserError.loginFailed) + sut.loginId = "dodle" + sut.password = "pw" + + await sut.login() + + XCTAssertNotNil(sut.errorMessage) + XCTAssertTrue(sut.isShowingError) + XCTAssertFalse(sut.isLoading) + XCTAssertFalse(session.isLoggedIn) + } + + func test_isShowingError_setFalse_clearsErrorMessage() { + sut.errorMessage = "에러" + + sut.isShowingError = false + + XCTAssertNil(sut.errorMessage) + } +} diff --git a/Rephoto_iOSTests/MockUserUseCaseProvider.swift b/Rephoto_iOSTests/MockUserUseCaseProvider.swift new file mode 100644 index 0000000..7a198f9 --- /dev/null +++ b/Rephoto_iOSTests/MockUserUseCaseProvider.swift @@ -0,0 +1,86 @@ +// +// MockUserUseCaseProvider.swift +// Rephoto_iOSTests +// +// SessionStore / LoginViewModel 테스트용 mock. +// - 각 UseCase의 결과를 Result로 미리 설정 +// - 호출 횟수/인자를 기록해 상호작용 검증 가능 +// + +import Foundation +@testable import Rephoto_iOS + +enum MockUserError: Error { + case loginFailed + case fetchFailed + case logoutFailed +} + +final class MockUserUseCaseProvider: UserUseCaseProviderProtocol { + + // 테스트에서 시나리오별로 설정하는 결과값 + var hasTokensResult = false + var loginResult: Result = .success(()) + var fetchUserResult: Result = .failure(MockUserError.fetchFailed) + var logoutResult: Result = .success(()) + + // 호출 기록 + private(set) var loginCallCount = 0 + private(set) var lastLoginId: String? + private(set) var lastPassword: String? + private(set) var fetchUserCallCount = 0 + private(set) var logoutCallCount = 0 + private(set) var onRefreshFailed: (@Sendable () -> Void)? + + func hasTokens() async -> Bool { + hasTokensResult + } + + func setOnRefreshFailed(_ handler: @escaping @Sendable () -> Void) { + onRefreshFailed = handler + } + + func login() -> LoginUseCaseProtocol { + StubLoginUseCase { id, password in + self.loginCallCount += 1 + self.lastLoginId = id + self.lastPassword = password + try self.loginResult.get() + } + } + + func fetchUser() -> FetchUserUseCaseProtocol { + StubFetchUserUseCase { + self.fetchUserCallCount += 1 + return try self.fetchUserResult.get() + } + } + + func logout() -> LogoutUseCaseProtocol { + StubLogoutUseCase { + self.logoutCallCount += 1 + try self.logoutResult.get() + } + } +} + +private struct StubLoginUseCase: LoginUseCaseProtocol { + let onExecute: (String, String) throws -> Void + func execute(loginId: String, password: String) async throws { + try onExecute(loginId, password) + } +} + +private struct StubFetchUserUseCase: FetchUserUseCaseProtocol { + let onExecute: () throws -> UserInfo + func execute() async throws -> UserInfo { + try onExecute() + } +} + +private struct StubLogoutUseCase: LogoutUseCaseProtocol { + let onExecute: () throws -> Void + func execute() async throws { + try onExecute() + } +} diff --git a/Rephoto_iOSTests/SessionStoreTests.swift b/Rephoto_iOSTests/SessionStoreTests.swift new file mode 100644 index 0000000..7733824 --- /dev/null +++ b/Rephoto_iOSTests/SessionStoreTests.swift @@ -0,0 +1,133 @@ +// +// SessionStoreTests.swift +// Rephoto_iOSTests +// +// 앱 전역 세션 상태(SessionStore)의 동작 검증. +// - restore: 토큰 유무에 따른 자동 로그인 +// - login/logout: 성공/실패 시 상태 전이 +// - 토큰 리프레시 실패 콜백 → 강제 로그아웃 +// + +import XCTest +@testable import Rephoto_iOS + +@MainActor +final class SessionStoreTests: XCTestCase { + + private var provider: MockUserUseCaseProvider! + private var sut: SessionStore! + + override func setUp() async throws { + try await super.setUp() + provider = MockUserUseCaseProvider() + sut = SessionStore(provider: provider) + } + + override func tearDown() async throws { + sut = nil + provider = nil + try await super.tearDown() + } + + private let stubUser = UserInfo(userId: 1, loginId: 100, name: "도연") + + // MARK: - restore + + func test_restore_withoutTokens_staysLoggedOut() async { + provider.hasTokensResult = false + + await sut.restore() + + XCTAssertFalse(sut.isLoggedIn) + XCTAssertNil(sut.userInfo) + XCTAssertEqual(provider.fetchUserCallCount, 0) + } + + func test_restore_withTokens_logsInAndFetchesUser() async { + provider.hasTokensResult = true + provider.fetchUserResult = .success(stubUser) + + await sut.restore() + + XCTAssertTrue(sut.isLoggedIn) + XCTAssertEqual(sut.userInfo?.userId, 1) + XCTAssertEqual(sut.name, "도연") + } + + func test_restore_withTokens_fetchUserFails_keepsLoggedInWithoutUserInfo() async { + provider.hasTokensResult = true + provider.fetchUserResult = .failure(MockUserError.fetchFailed) + + await sut.restore() + + XCTAssertTrue(sut.isLoggedIn) + XCTAssertNil(sut.userInfo) + XCTAssertEqual(sut.name, "리포토") + } + + // MARK: - login + + func test_login_success_setsLoggedInAndUserInfo() async throws { + provider.fetchUserResult = .success(stubUser) + + try await sut.login(id: "dodle", password: "pw") + + XCTAssertTrue(sut.isLoggedIn) + XCTAssertEqual(sut.userInfo?.name, "도연") + XCTAssertEqual(provider.lastLoginId, "dodle") + XCTAssertEqual(provider.lastPassword, "pw") + } + + func test_login_failure_throwsAndStaysLoggedOut() async { + provider.loginResult = .failure(MockUserError.loginFailed) + + do { + try await sut.login(id: "dodle", password: "pw") + XCTFail("로그인 실패 시 에러가 throw되어야 한다") + } catch {} + + XCTAssertFalse(sut.isLoggedIn) + XCTAssertEqual(provider.fetchUserCallCount, 0) + } + + // MARK: - logout + + func test_logout_clearsSessionState() async throws { + provider.fetchUserResult = .success(stubUser) + try await sut.login(id: "dodle", password: "pw") + + await sut.logout() + + XCTAssertFalse(sut.isLoggedIn) + XCTAssertNil(sut.userInfo) + XCTAssertEqual(provider.logoutCallCount, 1) + } + + func test_logout_serverFailure_stillClearsLocalState() async throws { + provider.fetchUserResult = .success(stubUser) + provider.logoutResult = .failure(MockUserError.logoutFailed) + try await sut.login(id: "dodle", password: "pw") + + await sut.logout() + + XCTAssertFalse(sut.isLoggedIn) + XCTAssertNil(sut.userInfo) + } + + // MARK: - 토큰 리프레시 실패 콜백 + + func test_refreshFailedCallback_forcesLogout() async throws { + provider.fetchUserResult = .success(stubUser) + try await sut.login(id: "dodle", password: "pw") + XCTAssertTrue(sut.isLoggedIn) + + provider.onRefreshFailed?() + // 콜백이 Task { @MainActor }로 감싸 실행되므로 메인 액터에 제어를 양보한다 + for _ in 0..<10 where sut.isLoggedIn { + await Task.yield() + } + + XCTAssertFalse(sut.isLoggedIn) + XCTAssertNil(sut.userInfo) + } +}