diff --git a/AGENTS.md b/AGENTS.md index ae7907d..5b26023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Data → Feature ```text MoziApp → CompositionRoot → RootFeature → AppCoordinator - bootstrapping → main(Placeholder) + bootstrapping → login / onboarding / main ``` ## 5. 핵심 규칙 요약 diff --git a/Projects/Feature/README.md b/Projects/Feature/README.md index 6a297ef..dc82c39 100644 --- a/Projects/Feature/README.md +++ b/Projects/Feature/README.md @@ -5,8 +5,9 @@ - Root / AppCoordinator / Scene 구성 ## 현재 상태 -- Root + AppCoordinator + Placeholder scene -- 딥링크: `mozi://home` +- Root + AppCoordinator + Login / OnboardingPlaceholder / Placeholder scene +- restore 기반 로그인 게이트 +- 딥링크: `mozi://home` (main 진입 후 처리) ## 의존 - 허용: Domain, SharedUtils, SharedDesignSystem, SharedLogger, ThirdParty, ThirdPartyUI @@ -23,10 +24,14 @@ ## 주요 진입점 - `RootFeature`, `RootView` - `AppCoordinatorFeature`, `AppCoordinatorView` +- `Scene/Login` +- `Scene/OnboardingPlaceholder` - `Scene/Placeholder` ## 테스트 포인트 -- 부트스트랩 후 main placeholder 진입 +- restore 분기: nil → login / profileCompleted false → onboarding / true → main +- LoginFeature 성공/실패/취소/중복 탭 방지 +- 온보딩 placeholder 로그아웃 후 login 복귀 - 딥링크 파싱 (`mozi://home`, https home, unknown) ## 관련 문서 diff --git a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift index d1d055a..8c8fd4c 100644 --- a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift +++ b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift @@ -1,3 +1,4 @@ +import Domain import Foundation import ThirdParty @@ -6,24 +7,53 @@ public struct AppCoordinatorFeature { @ObservableState public struct State: Equatable { public var phase: Phase = .bootstrapping + public var isRestoringSession = false public var pendingDeepLink: DeepLinkRoute? public var overlay = OverlayFeature.State() public init( phase: Phase = .bootstrapping, + isRestoringSession: Bool = false, pendingDeepLink: DeepLinkRoute? = nil, overlay: OverlayFeature.State = OverlayFeature.State() ) { self.phase = phase + self.isRestoringSession = isRestoringSession self.pendingDeepLink = pendingDeepLink self.overlay = overlay } public enum Phase: Equatable { case bootstrapping + case login(LoginFeature.State) + case onboarding(OnboardingPlaceholderFeature.State) case main(PlaceholderFeature.State) } + public var login: LoginFeature.State? { + get { + guard case let .login(state) = phase else { return nil } + return state + } + set { + if let newValue { + phase = .login(newValue) + } + } + } + + public var onboarding: OnboardingPlaceholderFeature.State? { + get { + guard case let .onboarding(state) = phase else { return nil } + return state + } + set { + if let newValue { + phase = .onboarding(newValue) + } + } + } + public var mainPlaceholder: PlaceholderFeature.State? { get { guard case let .main(state) = phase else { return nil } @@ -39,13 +69,18 @@ public struct AppCoordinatorFeature { public enum Action: Equatable { case onAppear + case bootstrapResponse(Result) case deepLinkReceived(URL) case routeDeepLink(DeepLinkRoute) case flushPendingDeepLink + case login(LoginFeature.Action) + case onboarding(OnboardingPlaceholderFeature.Action) case main(PlaceholderFeature.Action) case overlay(OverlayFeature.Action) } + @Dependency(\.authClient) var authClient + public init() {} public var body: some ReducerOf { @@ -53,6 +88,12 @@ public struct AppCoordinatorFeature { OverlayFeature() } Reduce(core) + .ifLet(\.login, action: \.login) { + LoginFeature() + } + .ifLet(\.onboarding, action: \.onboarding) { + OnboardingPlaceholderFeature() + } .ifLet(\.mainPlaceholder, action: \.main) { PlaceholderFeature() } @@ -61,11 +102,10 @@ public struct AppCoordinatorFeature { private func core(state: inout State, action: Action) -> Effect { switch action { case .onAppear: - guard case .bootstrapping = state.phase else { - return .none - } - state.phase = .main(PlaceholderFeature.State()) - return .send(.flushPendingDeepLink) + return bootstrapIfNeeded(state: &state) + + case let .bootstrapResponse(result): + return handleBootstrapResponse(state: &state, result: result) case let .deepLinkReceived(url): guard let route = DeepLinkRouter.parse(url) else { @@ -74,25 +114,109 @@ public struct AppCoordinatorFeature { return .send(.routeDeepLink(route)) case let .routeDeepLink(route): - switch state.phase { - case .bootstrapping: - state.pendingDeepLink = route - return .none - case .main: - // placeholder 골격: home 딥링크는 현재 main scene 유지 - _ = route - return .none - } + return handleDeepLinkRoute(state: &state, route: route) case .flushPendingDeepLink: - guard let route = state.pendingDeepLink else { - return .none + return flushPendingDeepLink(state: &state) + + case let .login(.delegate(.loggedIn(session))): + applySession(&state, session: session) + return .send(.flushPendingDeepLink) + + case .onboarding(.delegate(.loggedOut)): + state.phase = .login(LoginFeature.State()) + return .none + + case .login, .onboarding, .main, .overlay: + return .none + } + } + + private func bootstrapIfNeeded(state: inout State) -> Effect { + guard case .bootstrapping = state.phase else { + return .none + } + // 응답 전 중복 onAppear 가 와도 restore 는 한 번만 실행한다. + guard state.isRestoringSession == false else { + return .none + } + + state.isRestoringSession = true + return .run { [authClient] send in + do { + let session = try await authClient.restoreSession() + await send(.bootstrapResponse(.success(session))) + } catch let error as AuthError { + await send(.bootstrapResponse(.failure(error))) + } catch { + await send( + .bootstrapResponse( + .failure(.unknown(message: error.localizedDescription)) + ) + ) } - state.pendingDeepLink = nil - return .send(.routeDeepLink(route)) + } + } - case .main, .overlay: + private func handleBootstrapResponse( + state: inout State, + result: Result + ) -> Effect { + // 진행 중이던 restore 응답만 반영한다. + guard state.isRestoringSession else { return .none } + state.isRestoringSession = false + + switch result { + case let .success(session): + applySession(&state, session: session) + case .failure: + // restore 실패 시 안전하게 로그인 게이트로 보낸다. + state.phase = .login(LoginFeature.State()) + } + return .send(.flushPendingDeepLink) + } + + private func handleDeepLinkRoute( + state: inout State, + route: DeepLinkRoute + ) -> Effect { + switch state.phase { + case .bootstrapping, .login, .onboarding: + state.pendingDeepLink = route + return .none + case .main: + // placeholder 골격: home 딥링크는 현재 main scene 유지 + _ = route + return .none + } + } + + private func flushPendingDeepLink(state: inout State) -> Effect { + guard case .main = state.phase else { + return .none + } + guard let route = state.pendingDeepLink else { + return .none + } + state.pendingDeepLink = nil + return .send(.routeDeepLink(route)) + } + + private func applySession( + _ state: inout State, + session: AuthSession? + ) { + guard let session else { + state.phase = .login(LoginFeature.State()) + return + } + + if session.profileCompleted { + state.phase = .main(PlaceholderFeature.State()) + } else { + state.phase = .onboarding(OnboardingPlaceholderFeature.State()) + } } } diff --git a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift index 7df6b8c..ce08f65 100644 --- a/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift +++ b/Projects/Feature/Sources/AppCoordinator/AppCoordinatorView.swift @@ -14,6 +14,14 @@ public struct AppCoordinatorView: View { case .bootstrapping: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) + case .login: + if let loginStore = store.scope(state: \.login, action: \.login) { + LoginView(store: loginStore) + } + case .onboarding: + if let onboardingStore = store.scope(state: \.onboarding, action: \.onboarding) { + OnboardingPlaceholderView(store: onboardingStore) + } case .main: if let mainStore = store.scope(state: \.mainPlaceholder, action: \.main) { PlaceholderView(store: mainStore) diff --git a/Projects/Feature/Sources/Scene/Login/LoginFeature.swift b/Projects/Feature/Sources/Scene/Login/LoginFeature.swift new file mode 100644 index 0000000..d58d10a --- /dev/null +++ b/Projects/Feature/Sources/Scene/Login/LoginFeature.swift @@ -0,0 +1,111 @@ +import Domain +import Foundation +import ThirdParty + +@Reducer +public struct LoginFeature { + @ObservableState + public struct State: Equatable { + public var isLoading = false + public var errorMessage: String? + + public init( + isLoading: Bool = false, + errorMessage: String? = nil + ) { + self.isLoading = isLoading + self.errorMessage = errorMessage + } + } + + public enum Action: Equatable { + case onAppear + case kakaoLoginTapped + case appleLoginTapped + case loginResponse(Result) + case delegate(Delegate) + + public enum Delegate: Equatable { + case loggedIn(AuthSession) + } + } + + @Dependency(\.authClient) var authClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .none + + case .kakaoLoginTapped: + return login(state: &state, provider: .kakao) + + case .appleLoginTapped: + return login(state: &state, provider: .apple) + + case let .loginResponse(.success(session)): + state.isLoading = false + state.errorMessage = nil + return .send(.delegate(.loggedIn(session))) + + case let .loginResponse(.failure(error)): + state.isLoading = false + // 사용자 취소는 에러 메시지 없이 idle 복귀한다. + if case .cancelled = error { + state.errorMessage = nil + } else { + state.errorMessage = Self.errorMessage(for: error) + } + return .none + + case .delegate: + return .none + } + } + } + + private func login( + state: inout State, + provider: AuthProvider + ) -> Effect { + guard state.isLoading == false else { + return .none + } + + state.isLoading = true + state.errorMessage = nil + + return .run { [authClient] send in + do { + let session = try await authClient.login(provider) + await send(.loginResponse(.success(session))) + } catch let error as AuthError { + await send(.loginResponse(.failure(error))) + } catch { + await send(.loginResponse(.failure(.unknown(message: error.localizedDescription)))) + } + } + } + + private static func errorMessage(for error: AuthError) -> String { + switch error { + case .cancelled: + return "" + case .notConfigured: + return "로그인 설정이 완료되지 않았어요." + case .loginFailed: + return "로그인에 실패했어요" + case .network: + return "네트워크 연결을 확인해 주세요" + case .unauthorized: + return "로그인에 실패했어요" + case .storage: + return "로그인 정보를 저장하지 못했어요." + case .unknown: + return "알 수 없는 오류가 발생했어요." + } + } +} diff --git a/Projects/Feature/Sources/Scene/Login/LoginView.swift b/Projects/Feature/Sources/Scene/Login/LoginView.swift new file mode 100644 index 0000000..4a15292 --- /dev/null +++ b/Projects/Feature/Sources/Scene/Login/LoginView.swift @@ -0,0 +1,70 @@ +import SharedDesignSystem +import SwiftUI +import ThirdParty + +public struct LoginView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: CGFloat.ds.spacing.sm) { + DesignText( + "Mozi", + style: TextStyle.ds.heading.large32Bold, + color: Color.ds.text.neutral.white, + alignment: .center + ) + + DesignText( + "로그인하고 모지를 시작해요", + style: TextStyle.ds.body.medium16Regular, + color: Color.ds.text.neutral.basic, + alignment: .center + ) + } + + Spacer() + + VStack(spacing: CGFloat.ds.spacing.sm) { + if let errorMessage = store.errorMessage { + DesignText( + errorMessage, + style: TextStyle.ds.body.small14Regular, + color: Color.ds.text.primary.basic, + alignment: .center + ) + .padding(.bottom, CGFloat.ds.spacing.xs) + } + + SocialLoginButton(provider: .kakao) { + store.send(.kakaoLoginTapped) + } + .disabled(store.isLoading) + + SocialLoginButton(provider: .apple) { + store.send(.appleLoginTapped) + } + .disabled(store.isLoading) + + if store.isLoading { + ProgressView() + .tint(Color.ds.text.neutral.white) + .padding(.top, CGFloat.ds.spacing.xs) + } + } + .padding(.horizontal, CGFloat.ds.spacing.lg) + .padding(.bottom, CGFloat.ds.spacing.xl) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.ds.background.black.ignoresSafeArea()) + .task { + store.send(.onAppear) + } + } +} diff --git a/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderFeature.swift b/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderFeature.swift new file mode 100644 index 0000000..7d1d86f --- /dev/null +++ b/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderFeature.swift @@ -0,0 +1,98 @@ +import Domain +import Foundation +import ThirdParty + +@Reducer +public struct OnboardingPlaceholderFeature { + @ObservableState + public struct State: Equatable { + public var isLoggingOut = false + public var errorMessage: String? + + public init( + isLoggingOut: Bool = false, + errorMessage: String? = nil + ) { + self.isLoggingOut = isLoggingOut + self.errorMessage = errorMessage + } + } + + public enum Action: Equatable { + case onAppear + case logoutTapped + case logoutResponse(Result) + case delegate(Delegate) + + public enum Delegate: Equatable { + case loggedOut + } + } + + /// Result 성공 값용 빈 마커. Void 는 Equatable 이 아니다. + public struct EquatableVoid: Equatable, Sendable { + public init() {} + } + + @Dependency(\.authClient) var authClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .none + + case .logoutTapped: + guard state.isLoggingOut == false else { + return .none + } + state.isLoggingOut = true + state.errorMessage = nil + return .run { [authClient] send in + do { + // Data 계층 계약: + // - 원격 logout 실패는 삼키고 로컬 세션 삭제를 진행 + // - throw 는 로컬 clear 실패(storage)일 때만 올라온다 + try await authClient.logout() + await send(.logoutResponse(.success(EquatableVoid()))) + } catch let error as AuthError { + await send(.logoutResponse(.failure(error))) + } catch { + await send( + .logoutResponse( + .failure(.unknown(message: error.localizedDescription)) + ) + ) + } + } + + case .logoutResponse(.success): + state.isLoggingOut = false + state.errorMessage = nil + return .send(.delegate(.loggedOut)) + + case let .logoutResponse(.failure(error)): + // 로컬 세션이 남아 있을 수 있으므로 화면을 유지하고 재시도를 유도한다. + state.isLoggingOut = false + state.errorMessage = Self.errorMessage(for: error) + return .none + + case .delegate: + return .none + } + } + } + + private static func errorMessage(for error: AuthError) -> String { + switch error { + case .storage: + return "로그아웃 정보를 지우지 못했어요. 다시 시도해 주세요." + case .network: + return "네트워크 연결을 확인해 주세요" + default: + return "로그아웃에 실패했어요. 다시 시도해 주세요." + } + } +} diff --git a/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderView.swift b/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderView.swift new file mode 100644 index 0000000..9562d3b --- /dev/null +++ b/Projects/Feature/Sources/Scene/OnboardingPlaceholder/OnboardingPlaceholderView.swift @@ -0,0 +1,59 @@ +import SharedDesignSystem +import SwiftUI +import ThirdParty + +public struct OnboardingPlaceholderView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: CGFloat.ds.spacing.md) { + Spacer() + + DesignText( + "추가 정보 입력이 필요해요", + style: TextStyle.ds.title.large24Bold, + color: Color.ds.text.neutral.white, + alignment: .center + ) + + DesignText( + "온보딩 화면은 곧 연결될 예정이에요.", + style: TextStyle.ds.body.medium16Regular, + color: Color.ds.text.neutral.basic, + alignment: .center + ) + + Spacer() + + if let errorMessage = store.errorMessage { + DesignText( + errorMessage, + style: TextStyle.ds.body.small14Regular, + color: Color.ds.text.primary.basic, + alignment: .center + ) + } + + DesignButton( + "로그아웃", + variant: .outlined, + size: .lg, + isEnabled: store.isLoggingOut == false, + isFullWidth: true + ) { + store.send(.logoutTapped) + } + .padding(.horizontal, CGFloat.ds.spacing.lg) + .padding(.bottom, CGFloat.ds.spacing.xl) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.ds.background.black.ignoresSafeArea()) + .task { + store.send(.onAppear) + } + } +} diff --git a/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift b/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift index 6ee1c6e..7b15ef7 100644 --- a/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift +++ b/Projects/Feature/Tests/AppCoordinator/AppCoordinatorFeatureTests.swift @@ -1,19 +1,265 @@ +import Domain import Feature import ThirdParty import XCTest @MainActor final class AppCoordinatorFeatureTests: XCTestCase { - func test_부트스트랩이_끝나면_메인_플레이스홀더로_진입() async { + func test_세션없으면_로그인으로_진입() async { let store = TestStore( initialState: AppCoordinatorFeature.State(phase: .bootstrapping) ) { AppCoordinatorFeature() + } withDependencies: { + $0.authClient.restoreSession = { nil } } await store.send(.onAppear) { + $0.isRestoringSession = true + } + await store.receive(.bootstrapResponse(.success(nil))) { + $0.isRestoringSession = false + $0.phase = .login(LoginFeature.State()) + } + await store.receive(.flushPendingDeepLink) + } + + func test_프로필미완료_세션이면_온보딩으로_진입() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + let store = TestStore( + initialState: AppCoordinatorFeature.State(phase: .bootstrapping) + ) { + AppCoordinatorFeature() + } withDependencies: { + $0.authClient.restoreSession = { session } + } + + await store.send(.onAppear) { + $0.isRestoringSession = true + } + await store.receive(.bootstrapResponse(.success(session))) { + $0.isRestoringSession = false + $0.phase = .onboarding(OnboardingPlaceholderFeature.State()) + } + await store.receive(.flushPendingDeepLink) + } + + func test_프로필완료_세션이면_메인으로_진입() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: false, + profileCompleted: true + ) + let store = TestStore( + initialState: AppCoordinatorFeature.State(phase: .bootstrapping) + ) { + AppCoordinatorFeature() + } withDependencies: { + $0.authClient.restoreSession = { session } + } + + await store.send(.onAppear) { + $0.isRestoringSession = true + } + await store.receive(.bootstrapResponse(.success(session))) { + $0.isRestoringSession = false + $0.phase = .main(PlaceholderFeature.State()) + } + await store.receive(.flushPendingDeepLink) + } + + func test_로그인_성공_후_프로필완료면_메인으로_전환() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: false, + profileCompleted: true + ) + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .login(LoginFeature.State()) + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.login(.delegate(.loggedIn(session)))) { $0.phase = .main(PlaceholderFeature.State()) } - await store.receive(\.flushPendingDeepLink) + await store.receive(.flushPendingDeepLink) + } + + func test_로그인_성공_후_프로필미완료면_온보딩으로_전환() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .login(LoginFeature.State()) + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.login(.delegate(.loggedIn(session)))) { + $0.phase = .onboarding(OnboardingPlaceholderFeature.State()) + } + await store.receive(.flushPendingDeepLink) + } + + func test_온보딩_로그아웃_delegate면_로그인으로_전환() async { + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .onboarding(OnboardingPlaceholderFeature.State()) + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.onboarding(.delegate(.loggedOut))) { + $0.phase = .login(LoginFeature.State()) + } + } + + func test_restore_실패하면_로그인으로_진입() async { + let store = TestStore( + initialState: AppCoordinatorFeature.State(phase: .bootstrapping) + ) { + AppCoordinatorFeature() + } withDependencies: { + $0.authClient.restoreSession = { + throw AuthError.storage(message: "keychain") + } + } + + await store.send(.onAppear) { + $0.isRestoringSession = true + } + await store.receive(.bootstrapResponse(.failure(.storage(message: "keychain")))) { + $0.isRestoringSession = false + $0.phase = .login(LoginFeature.State()) + } + await store.receive(.flushPendingDeepLink) + } + + func test_로그인_중_딥링크는_pending으로_유지() async { + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .login(LoginFeature.State()) + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.routeDeepLink(.home)) { + $0.pendingDeepLink = .home + } + } + + func test_온보딩_중_flush는_딥링크를_처리하지_않음() async { + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .onboarding(OnboardingPlaceholderFeature.State()), + pendingDeepLink: .home + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.flushPendingDeepLink) + } + + func test_메인_진입_후_pending_딥링크를_flush() async { + let store = TestStore( + initialState: AppCoordinatorFeature.State( + phase: .main(PlaceholderFeature.State()), + pendingDeepLink: .home + ) + ) { + AppCoordinatorFeature() + } + + await store.send(.flushPendingDeepLink) { + $0.pendingDeepLink = nil + } + await store.receive(.routeDeepLink(.home)) + } + + func test_부트스트랩_중복_onAppear는_restore를_한_번만_호출() async { + let gate = RestoreGate() + let store = TestStore( + initialState: AppCoordinatorFeature.State(phase: .bootstrapping) + ) { + AppCoordinatorFeature() + } withDependencies: { + $0.authClient.restoreSession = { + await gate.markStartedAndWait() + return nil + } + } + + await store.send(.onAppear) { + $0.isRestoringSession = true + } + // restore 응답을 붙잡아 둔 상태에서 중복 onAppear 를 보낸다. + await gate.waitUntilStarted() + await store.send(.onAppear) + await gate.release() + + await store.receive(.bootstrapResponse(.success(nil))) { + $0.isRestoringSession = false + $0.phase = .login(LoginFeature.State()) + } + await store.receive(.flushPendingDeepLink) + + let count = await gate.startCount + XCTAssertEqual(count, 1) + } +} + +private actor RestoreGate { + private(set) var startCount = 0 + private var startedContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + private var isStarted = false + private var isReleased = false + + func markStartedAndWait() async { + startCount += 1 + if isStarted == false { + isStarted = true + startedContinuation?.resume() + startedContinuation = nil + } + if isReleased { + return + } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilStarted() async { + if isStarted { + return + } + await withCheckedContinuation { continuation in + startedContinuation = continuation + } + } + + func release() { + isReleased = true + releaseContinuation?.resume() + releaseContinuation = nil } } diff --git a/Projects/Feature/Tests/Login/LoginFeatureTests.swift b/Projects/Feature/Tests/Login/LoginFeatureTests.swift new file mode 100644 index 0000000..7e32bb6 --- /dev/null +++ b/Projects/Feature/Tests/Login/LoginFeatureTests.swift @@ -0,0 +1,143 @@ +import Domain +import Feature +import ThirdParty +import XCTest + +@MainActor +final class LoginFeatureTests: XCTestCase { + func test_카카오_로그인_성공하면_delegate_loggedIn() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: false, + profileCompleted: true + ) + let store = TestStore( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { provider in + XCTAssertEqual(provider, .kakao) + return session + } + } + + await store.send(.kakaoLoginTapped) { + $0.isLoading = true + $0.errorMessage = nil + } + await store.receive(.loginResponse(.success(session))) { + $0.isLoading = false + $0.errorMessage = nil + } + await store.receive(.delegate(.loggedIn(session))) + } + + func test_애플_로그인_성공하면_delegate_loggedIn() async { + let session = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + let store = TestStore( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { provider in + XCTAssertEqual(provider, .apple) + return session + } + } + + await store.send(.appleLoginTapped) { + $0.isLoading = true + $0.errorMessage = nil + } + await store.receive(.loginResponse(.success(session))) { + $0.isLoading = false + $0.errorMessage = nil + } + await store.receive(.delegate(.loggedIn(session))) + } + + func test_로그인_실패하면_에러메시지_표시() async { + let store = TestStore( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { _ in + throw AuthError.loginFailed + } + } + + await store.send(.appleLoginTapped) { + $0.isLoading = true + $0.errorMessage = nil + } + await store.receive(.loginResponse(.failure(.loginFailed))) { + $0.isLoading = false + $0.errorMessage = "로그인에 실패했어요" + } + } + + func test_네트워크_실패하면_연결확인_메시지_표시() async { + let store = TestStore( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { _ in + throw AuthError.network + } + } + + await store.send(.kakaoLoginTapped) { + $0.isLoading = true + $0.errorMessage = nil + } + await store.receive(.loginResponse(.failure(.network))) { + $0.isLoading = false + $0.errorMessage = "네트워크 연결을 확인해 주세요" + } + } + + func test_로그인_취소하면_에러메시지_없이_idle_복귀() async { + let store = TestStore( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { _ in + throw AuthError.cancelled + } + } + + await store.send(.kakaoLoginTapped) { + $0.isLoading = true + $0.errorMessage = nil + } + await store.receive(.loginResponse(.failure(.cancelled))) { + $0.isLoading = false + $0.errorMessage = nil + } + } + + func test_로딩중_중복탭은_무시() async { + let store = TestStore( + initialState: LoginFeature.State(isLoading: true) + ) { + LoginFeature() + } withDependencies: { + $0.authClient.login = { _ in + XCTFail("loading 중 login이 호출되면 안 됩니다.") + throw AuthError.loginFailed + } + } + + await store.send(.kakaoLoginTapped) + } +} diff --git a/Projects/Feature/Tests/OnboardingPlaceholder/OnboardingPlaceholderFeatureTests.swift b/Projects/Feature/Tests/OnboardingPlaceholder/OnboardingPlaceholderFeatureTests.swift new file mode 100644 index 0000000..197d1e8 --- /dev/null +++ b/Projects/Feature/Tests/OnboardingPlaceholder/OnboardingPlaceholderFeatureTests.swift @@ -0,0 +1,48 @@ +import Domain +import Feature +import ThirdParty +import XCTest + +@MainActor +final class OnboardingPlaceholderFeatureTests: XCTestCase { + func test_로그아웃_성공하면_delegate_loggedOut() async { + let store = TestStore( + initialState: OnboardingPlaceholderFeature.State() + ) { + OnboardingPlaceholderFeature() + } withDependencies: { + $0.authClient.logout = {} + } + + await store.send(.logoutTapped) { + $0.isLoggingOut = true + $0.errorMessage = nil + } + await store.receive(.logoutResponse(.success(OnboardingPlaceholderFeature.EquatableVoid()))) { + $0.isLoggingOut = false + $0.errorMessage = nil + } + await store.receive(.delegate(.loggedOut)) + } + + func test_로그아웃_로컬삭제_실패면_에러표시_후_유지() async { + let store = TestStore( + initialState: OnboardingPlaceholderFeature.State() + ) { + OnboardingPlaceholderFeature() + } withDependencies: { + $0.authClient.logout = { + throw AuthError.storage(message: "keychain") + } + } + + await store.send(.logoutTapped) { + $0.isLoggingOut = true + $0.errorMessage = nil + } + await store.receive(.logoutResponse(.failure(.storage(message: "keychain")))) { + $0.isLoggingOut = false + $0.errorMessage = "로그아웃 정보를 지우지 못했어요. 다시 시도해 주세요." + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 44840c2..f741c17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,14 +70,17 @@ MoziApp → RootView → AppCoordinatorView ``` -앱 상태(스캐폴딩): +앱 상태: ```text bootstrapping - → main(Placeholder) + → restoreSession() + nil → login(LoginFeature) + profileCompleted == false → onboarding(OnboardingPlaceholderFeature) + profileCompleted == true → main(Placeholder) ``` -Auth 인프라는 Domain/Data/App live 등록까지 존재하고, 게이트 UI / MainTab 샘플은 후속이다. +Auth 인프라와 로그인 게이트 UI 는 존재한다. 소셜 SDK 실연동과 MainTab 은 후속이다. ---