diff --git a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift index 518137f5..0641f54f 100644 --- a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift +++ b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift @@ -7,6 +7,7 @@ import Foundation +import AuthDomainInterface import DDDSharedUI import OnBoarding import Profile @@ -22,6 +23,7 @@ public struct ProfileCoordinator: Sendable { @ObservableState public struct State: Equatable { var routes: [Route] + @Shared(.isEditingGeneration) var editGeneration public init() { routes = [.root(.profile(.init()), embedInNavigationView: true)] @@ -51,7 +53,7 @@ public struct ProfileCoordinator: Sendable { } - public enum NavigationAction: Equatable { + public enum NavigationAction: Equatable, Sendable { case presentLogin case presentRoot case presentStaff @@ -59,6 +61,12 @@ public struct ProfileCoordinator: Sendable { } func handleRoute(state: inout State, action: Action) -> Effect { + defer { + // 버튼뿐 아니라 시스템 뒤로가기(updateRoutes)로 편집 화면이 닫혀도 조회를 재개한다. + if state.editGeneration, !state.routes.contains(where: { $0.screen.onBoarding != nil }) { + state.$editGeneration.withLock { $0 = false } + } + } switch action { case .router(let action): return routerAction(state: &state, action: action) @@ -177,6 +185,7 @@ extension ProfileCoordinator { state: inout State, action: NavigationAction ) -> Effect { + state.$editGeneration.withLock { $0 = false } switch action { case .presentLogin: return .none diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index c9aef3df..574073ae 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -80,6 +80,7 @@ public struct AppReducer: Sendable { @Dependency(\.continuousClock) var clock @Shared(.userSession) var userSession @Shared(.staffRole) var staffRole + @Shared(.isEditingGeneration) var editGeneration private enum CancelID: Hashable { case transition @@ -176,6 +177,7 @@ public struct AppReducer: Sendable { // 로그아웃·토큰 만료 이후 새 계정의 프로필 조회 전까지 이전 ID가 남지 않도록 한다. $userSession.withLock { $0 = .empty } $staffRole.withLock { $0 = nil } + $editGeneration.withLock { $0 = false } state = .auth(.init()) return .none diff --git a/Projects/App/Tests/Sources/LogoutNavigationTests.swift b/Projects/App/Tests/Sources/LogoutNavigationTests.swift index 4f3d8599..39c3c9cd 100644 --- a/Projects/App/Tests/Sources/LogoutNavigationTests.swift +++ b/Projects/App/Tests/Sources/LogoutNavigationTests.swift @@ -12,6 +12,8 @@ import Testing @testable import DDDStorage import AuthDomainInterface import DDDStorageInterface +@testable import Profile +import ProfileDomainInterface import ComposableArchitecture import SQLiteData @@ -64,16 +66,19 @@ struct LogoutNavigationTests { } operation: { @Shared(.userSession) var session @Shared(.staffRole) var role + @Shared(.isEditingGeneration) var editing $session.withLock { $0 = UserSession(userID: 42, name: "이전 계정", userRole: .manager, accessToken: "old-token") } $role.withLock { $0 = .manager } + $editing.withLock { $0 = true } let store = Store(initialState: AppReducer.State.member(.init())) { AppReducer() } await store.send(expired ? .async(.refreshTokenExpired) : .view(.presentAuth)).finish() #expect(session == .empty) #expect(role == nil) + #expect(!editing) guard case .auth = store.state else { Issue.record("세션을 비운 뒤 인증 화면으로 전환해야 한다") return @@ -84,6 +89,74 @@ struct LogoutNavigationTests { } } + @Test("기수 변경을 버튼이나 시스템 뒤로가기로 취소하면 내 정보 재조회가 가능하다", arguments: [false, true]) + func generationEditCancellationRestoresProfileLoading(systemBack: Bool) async { + await withDependencies { + $0.defaultInMemoryStorage = InMemoryStorage() + $0.profileUseCase = MockProfileRepository() + } operation: { + let store = Store(initialState: ProfileCoordinator.State()) { ProfileCoordinator() } + await store.send(.router(.routeAction( + id: 0, action: .profile(.delegate(.presentEditGeneration)) + ))).finish() + #expect(store.editGeneration) + #expect(store.routes.count == 2) + + // 편집 도중의 단계 이동/뒤로가기는 편집 모드를 유지한다. + await store.send(.router(.routeAction( + id: 1, action: .onBoarding(.router(.routeAction( + id: 0, action: .InviteCode(.delegate(.presentSignUpName)) + ))) + ))).finish() + await store.send(.router(.routeAction( + id: 1, action: .onBoarding(.router(.routeAction( + id: 1, action: .onBoardingName(.delegate(.presentBack)) + ))) + ))).finish() + #expect(store.editGeneration) + + if systemBack { + await store.send(.router(.updateRoutes(Array(store.routes.prefix(1))))).finish() + } else { + await store.send(.router(.routeAction( + id: 1, action: .onBoarding(.router(.routeAction( + id: 0, action: .InviteCode(.delegate(.presentBack)) + ))) + ))).finish() + } + #expect(!store.editGeneration) + #expect(store.routes.count == 1) + + await store.send(.router(.routeAction(id: 0, action: .profile(.async(.fetchUser))))).finish() + #expect(store.routes.first?.screen.profile?.viewState == .loaded) + #expect(store.routes.first?.screen.profile?.profile != nil) + + // 내 정보를 닫았다 다시 열어도 같은 공유 플래그가 조회를 막지 않는다. + let reopened = Store(initialState: ProfileCoordinator.State()) { ProfileCoordinator() } + await reopened.send(.router(.routeAction(id: 0, action: .profile(.async(.fetchUser))))).finish() + #expect(reopened.routes.first?.screen.profile?.profile != nil) + } + } + + @Test("기수 변경 후 홈이나 로그인으로 전환하면 편집 모드를 종료한다", arguments: [ + ProfileCoordinator.NavigationAction.presentMember, + .presentStaff, + .presentLogin + ]) + func generationEditNavigationClearsEditing(action: ProfileCoordinator.NavigationAction) async { + await withDependencies { + $0.defaultInMemoryStorage = InMemoryStorage() + } operation: { + let store = Store(initialState: ProfileCoordinator.State()) { ProfileCoordinator() } + await store.send(.router(.routeAction( + id: 0, action: .profile(.delegate(.presentEditGeneration)) + ))).finish() + #expect(store.editGeneration) + await store.send(.navigation(action)).finish() + #expect(!store.editGeneration) + } + } + @Test("Profile 로그아웃은 화면을 pop하지 않고 로그인 전환을 요청한다") func profileLogoutRequestsLoginWithoutPoppingRoutes() async { let store = TestStore(initialState: ProfileCoordinator.State()) { diff --git a/Projects/Domain/AuthDomain/Interface/Sources/Entity/UserSessionSharedKey.swift b/Projects/Domain/AuthDomain/Interface/Sources/Entity/UserSessionSharedKey.swift index 5744562b..1900eaa6 100644 --- a/Projects/Domain/AuthDomain/Interface/Sources/Entity/UserSessionSharedKey.swift +++ b/Projects/Domain/AuthDomain/Interface/Sources/Entity/UserSessionSharedKey.swift @@ -13,6 +13,14 @@ import ProfileDomainInterface import Dependencies import Sharing +public extension SharedReaderKey where Self == InMemoryKey.Default { + /// 기수 변경 화면이 열려 있는 동안만 유지한다. + /// 이전 버전의 UserDefaults 값은 읽지 않아 중단된 편집이 다음 실행의 조회를 막지 않는다. + static var isEditingGeneration: Self { + Self[.inMemory("isEditingGeneration"), default: false] + } +} + public extension SharedReaderKey where Self == PersistentSharedKey.Default { /// 앱 실행 사이에 유지되는 사용자 세션 메타데이터입니다. /// diff --git a/Projects/Feature/Auth/Sources/Login/Reducer/LoginFeature.swift b/Projects/Feature/Auth/Sources/Login/Reducer/LoginFeature.swift index 13129a07..531f348f 100644 --- a/Projects/Feature/Auth/Sources/Login/Reducer/LoginFeature.swift +++ b/Projects/Feature/Auth/Sources/Login/Reducer/LoginFeature.swift @@ -26,7 +26,7 @@ public struct LoginFeature { @Shared var userSession: UserSession @Shared(.staffRole) var staffRole - @Shared(.appStorage("editGeneration")) var editGeneration: Bool = false + @Shared(.isEditingGeneration) var editGeneration var currentSocialType: SocialType? @Presents public var customAlert: CustomAlertState? @@ -199,10 +199,9 @@ extension LoginFeature { let role = login.role ?? .member state.$staffRole.withLock { $0 = login.isNewUser ? nil : role } state.$userSession.withLock { $0.userRole = role } + state.$editGeneration.withLock { $0 = false } if login.isNewUser { - // 신규 가입: editGeneration 잔재(true)로 인한 editProfile 오분기 방지 → 회원가입(signUp) 강제 - state.$editGeneration.withLock { $0 = false } return .send(.view(.showPolicyPopUp)) } else if role == .manager { return .send(.delegate(.presentStaffMain)) diff --git a/Projects/Feature/Auth/Tests/Sources/LoginActionCoverageTests.swift b/Projects/Feature/Auth/Tests/Sources/LoginActionCoverageTests.swift index 237a52f9..70658be5 100644 --- a/Projects/Feature/Auth/Tests/Sources/LoginActionCoverageTests.swift +++ b/Projects/Feature/Auth/Tests/Sources/LoginActionCoverageTests.swift @@ -135,11 +135,12 @@ struct LoginActionCoverageTests { /// 기존 멤버 로그인은 역할을 저장하고 멤버 메인으로 이동한다. @Test("기존 멤버 로그인 성공은 역할 저장 후 멤버 메인으로 이동한다") func inner_loginResponse_existingMember_navigatesToMemberMain() async { - let store = Self.makeStore() + let store = Self.makeStore(configureState: { $0.editGeneration = true }) await store.send(.inner(.loginResponse(.success(LoginTestFixture.member)))) { $0.staffRole = .member $0.userSession.userRole = .member + $0.editGeneration = false } await store.receive(\.delegate.presentMemberMain) } @@ -147,11 +148,12 @@ struct LoginActionCoverageTests { /// 운영진 로그인은 staffMain 으로 분기한다. @Test("운영진 로그인 성공은 운영진 메인으로 이동한다") func inner_loginResponse_manager_navigatesToStaffMain() async { - let store = Self.makeStore() + let store = Self.makeStore(configureState: { $0.editGeneration = true }) await store.send(.inner(.loginResponse(.success(LoginTestFixture.manager)))) { $0.staffRole = .manager $0.userSession.userRole = .manager + $0.editGeneration = false } await store.receive(\.delegate.presentStaffMain) } @@ -159,11 +161,12 @@ struct LoginActionCoverageTests { /// role 이 nil 인 경계값은 member 로 기본 처리된다. @Test("role 이 nil 인 로그인 성공은 member 로 기본 처리된다") func inner_loginResponse_nilRole_defaultsToMember() async { - let store = Self.makeStore() + let store = Self.makeStore(configureState: { $0.editGeneration = true }) await store.send(.inner(.loginResponse(.success(LoginTestFixture.roleless)))) { $0.staffRole = .member $0.userSession.userRole = .member + $0.editGeneration = false } await store.receive(\.delegate.presentMemberMain) } diff --git a/Projects/Feature/OnBoarding/Sources/SelectManaging/Reducer/SelectManagingFeature.swift b/Projects/Feature/OnBoarding/Sources/SelectManaging/Reducer/SelectManagingFeature.swift index 6a5ad338..768d16a7 100644 --- a/Projects/Feature/OnBoarding/Sources/SelectManaging/Reducer/SelectManagingFeature.swift +++ b/Projects/Feature/OnBoarding/Sources/SelectManaging/Reducer/SelectManagingFeature.swift @@ -35,7 +35,7 @@ public struct SelectManagingFeature { var managers: IdentifiedArrayOf = [] @Shared(.userSession) var userSession - @Shared(.appStorage("editGeneration")) var editGeneration: Bool = false + @Shared(.isEditingGeneration) var editGeneration @Shared(.staffRole) var staffRole @Presents var alert: AlertState? } @@ -300,7 +300,6 @@ extension SelectManagingFeature { } case let .failure(error): - state.$editGeneration.withLock { $0 = false } state.alert = AlertState { TextState("프로필 수정 실패") } actions: { diff --git a/Projects/Feature/OnBoarding/Sources/SelectTeam/Reducer/SelectTeamFeature.swift b/Projects/Feature/OnBoarding/Sources/SelectTeam/Reducer/SelectTeamFeature.swift index 8ad0d42f..a68c96cd 100644 --- a/Projects/Feature/OnBoarding/Sources/SelectTeam/Reducer/SelectTeamFeature.swift +++ b/Projects/Feature/OnBoarding/Sources/SelectTeam/Reducer/SelectTeamFeature.swift @@ -39,7 +39,7 @@ public struct SelectTeamFeature { @Shared(.userSession) var userSession - @Shared(.appStorage("editGeneration")) var editGeneration: Bool = false + @Shared(.isEditingGeneration) var editGeneration @Shared(.staffRole) var staffRole @Presents var alert: AlertState? } @@ -322,7 +322,6 @@ extension SelectTeamFeature { } case .failure(let error): - state.$editGeneration.withLock { $0 = false } state.alert = AlertState { TextState("프로필 수정 실패") } actions: { diff --git a/Projects/Feature/OnBoarding/Tests/Sources/SelectManagingReducerTests.swift b/Projects/Feature/OnBoarding/Tests/Sources/SelectManagingReducerTests.swift index 6390892e..0317fdb1 100644 --- a/Projects/Feature/OnBoarding/Tests/Sources/SelectManagingReducerTests.swift +++ b/Projects/Feature/OnBoarding/Tests/Sources/SelectManagingReducerTests.swift @@ -141,14 +141,18 @@ struct SelectManagingReducerTests { func editGenerationNavigatesToMember() async { let profile = OnBoardingCoverageFixture.memberProfile let authUseCase = MockAuthRepository.refreshSuccess() - let appStorage = UserDefaults.inMemory let inMemoryStorage = InMemoryStorage() - appStorage.set(true, forKey: "editGeneration") + let initialState = withDependencies { + $0.defaultInMemoryStorage = inMemoryStorage + } operation: { + var state = SelectManagingFeature.State() + state.$editGeneration.withLock { $0 = true } + return state + } - let store = TestStore(initialState: SelectManagingFeature.State()) { + let store = TestStore(initialState: initialState) { SelectManagingFeature() } withDependencies: { - $0.defaultAppStorage = appStorage $0.defaultInMemoryStorage = inMemoryStorage $0.profileUseCase = StubProfileUseCase(profile: profile) $0.authUseCase = authUseCase @@ -176,14 +180,18 @@ struct SelectManagingReducerTests { @Test("기수 변경 후 토큰 갱신 실패 시 운영진 API를 호출하지 않고 로그인으로 이동한다") func credentialRefreshFailureNavigatesToLogin() async { let profile = OnBoardingCoverageFixture.managerProfile - let appStorage = UserDefaults.inMemory let inMemoryStorage = InMemoryStorage() - appStorage.set(true, forKey: "editGeneration") + let initialState = withDependencies { + $0.defaultInMemoryStorage = inMemoryStorage + } operation: { + var state = SelectManagingFeature.State() + state.$editGeneration.withLock { $0 = true } + return state + } - let store = TestStore(initialState: SelectManagingFeature.State()) { + let store = TestStore(initialState: initialState) { SelectManagingFeature() } withDependencies: { - $0.defaultAppStorage = appStorage $0.defaultInMemoryStorage = inMemoryStorage $0.profileUseCase = StubProfileUseCase(profile: profile) $0.authUseCase = MockAuthRepository.tokenExpired() @@ -223,7 +231,7 @@ struct SelectManagingReducerTests { await store.receive(\.delegate.presentManager) } - @Test("프로필 수정 실패는 기수 변경 플래그를 내리고 실패 알럿을 표시한다") + @Test("프로필 수정 실패는 기수 변경 상태를 유지하고 실패 알럿을 표시한다") func editProfileFailurePresentsAlert() async { var state = SelectManagingFeature.State() state.editGeneration = true @@ -235,7 +243,6 @@ struct SelectManagingReducerTests { let error = ProfileError.profileNotFound await store.send(.inner(.editProfileResponse(.failure(error)))) { - $0.editGeneration = false $0.alert = AlertState { TextState("프로필 수정 실패") } actions: { @@ -248,6 +255,44 @@ struct SelectManagingReducerTests { } } + @Test("기수 변경 수정 실패 후 재시도는 회원가입 대신 프로필 수정을 다시 호출한다") + func retryAfterEditProfileFailureKeepsEditFlow() async { + var state = SelectManagingFeature.State() + state.$editGeneration.withLock { $0 = true } + + let store = TestStore(initialState: state) { + SelectManagingFeature() + } withDependencies: { + $0.profileUseCase = StubProfileUseCase(editFailure: .profileUpdateFailed) + $0.signUpUseCase = StubSignUpUseCase(failure: .accountAlreadyExists) + } + let error = ProfileError.from(EditProfileError.profileUpdateFailed) + + for _ in 0..<2 { + await store.send(.view(.signUp)) + await store.receive { + guard case .async(.editProfile) = $0 else { return false } + return true + } + await store.receive(\.inner) { + $0.alert = AlertState { + TextState("프로필 수정 실패") + } actions: { + ButtonState(action: .confirmTapped) { + TextState("확인") + } + } message: { + TextState(error.errorDescription ?? "알 수 없는 오류가 발생했습니다.") + } + } + #expect(store.state.editGeneration) + await store.send(.scope(.alert(.dismiss))) { + $0.alert = nil + } + } + await store.finish() + } + // MARK: - Delegate / Scope / Binding @Test("delegate 액션은 모두 부수효과 없이 소비된다") diff --git a/Projects/Feature/OnBoarding/Tests/Sources/SelectTeamReducerTests.swift b/Projects/Feature/OnBoarding/Tests/Sources/SelectTeamReducerTests.swift index df1b4f3d..8e394d52 100644 --- a/Projects/Feature/OnBoarding/Tests/Sources/SelectTeamReducerTests.swift +++ b/Projects/Feature/OnBoarding/Tests/Sources/SelectTeamReducerTests.swift @@ -159,14 +159,18 @@ struct SelectTeamReducerTests { func editGenerationSignUpEditsProfile() async { let profile = OnBoardingCoverageFixture.managerProfile let authUseCase = MockAuthRepository.refreshSuccess() - let appStorage = UserDefaults.inMemory let inMemoryStorage = InMemoryStorage() - appStorage.set(true, forKey: "editGeneration") + let initialState = withDependencies { + $0.defaultInMemoryStorage = inMemoryStorage + } operation: { + var state = SelectTeamFeature.State() + state.$editGeneration.withLock { $0 = true } + return state + } - let store = TestStore(initialState: SelectTeamFeature.State()) { + let store = TestStore(initialState: initialState) { SelectTeamFeature() } withDependencies: { - $0.defaultAppStorage = appStorage $0.defaultInMemoryStorage = inMemoryStorage $0.profileUseCase = StubProfileUseCase(profile: profile) $0.authUseCase = authUseCase @@ -194,14 +198,18 @@ struct SelectTeamReducerTests { @Test("기수 변경 후 토큰 갱신 실패 시 운영진 API를 호출하지 않고 로그인으로 이동한다") func credentialRefreshFailureNavigatesToLogin() async { let profile = OnBoardingCoverageFixture.managerProfile - let appStorage = UserDefaults.inMemory let inMemoryStorage = InMemoryStorage() - appStorage.set(true, forKey: "editGeneration") + let initialState = withDependencies { + $0.defaultInMemoryStorage = inMemoryStorage + } operation: { + var state = SelectTeamFeature.State() + state.$editGeneration.withLock { $0 = true } + return state + } - let store = TestStore(initialState: SelectTeamFeature.State()) { + let store = TestStore(initialState: initialState) { SelectTeamFeature() } withDependencies: { - $0.defaultAppStorage = appStorage $0.defaultInMemoryStorage = inMemoryStorage $0.profileUseCase = StubProfileUseCase(profile: profile) $0.authUseCase = MockAuthRepository.tokenExpired() @@ -241,7 +249,7 @@ struct SelectTeamReducerTests { await store.receive(\.delegate.presentMember) } - @Test("프로필 수정 실패는 기수 변경 플래그를 내리고 실패 알럿을 표시한다") + @Test("프로필 수정 실패는 기수 변경 상태를 유지하고 실패 알럿을 표시한다") func editProfileFailurePresentsAlert() async { var state = SelectTeamFeature.State() state.editGeneration = true @@ -253,7 +261,6 @@ struct SelectTeamReducerTests { let error = ProfileError.loadFailed await store.send(.inner(.editProfileResponse(.failure(error)))) { - $0.editGeneration = false $0.alert = AlertState { TextState("프로필 수정 실패") } actions: { @@ -283,6 +290,44 @@ struct SelectTeamReducerTests { await store.receive(\.inner) } + @Test("기수 변경 수정 실패 후 재시도는 회원가입 대신 프로필 수정을 다시 호출한다") + func retryAfterEditProfileFailureKeepsEditFlow() async { + var state = SelectTeamFeature.State() + state.$editGeneration.withLock { $0 = true } + + let store = TestStore(initialState: state) { + SelectTeamFeature() + } withDependencies: { + $0.profileUseCase = StubProfileUseCase(editFailure: .profileUpdateFailed) + $0.signUpUseCase = StubSignUpUseCase(failure: .accountAlreadyExists) + } + let error = ProfileError.from(EditProfileError.profileUpdateFailed) + + for _ in 0..<2 { + await store.send(.view(.signUp)) + await store.receive { + guard case .async(.editProfile) = $0 else { return false } + return true + } + await store.receive(\.inner) { + $0.alert = AlertState { + TextState("프로필 수정 실패") + } actions: { + ButtonState(action: .confirmTapped) { + TextState("확인") + } + } message: { + TextState(error.errorDescription ?? "알 수 없는 오류가 발생했습니다.") + } + } + #expect(store.state.editGeneration) + await store.send(.scope(.alert(.dismiss))) { + $0.alert = nil + } + } + await store.finish() + } + // MARK: - Delegate / Scope / Binding @Test("delegate 액션은 모두 부수효과 없이 소비된다") diff --git a/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift index 31f6954c..08411c6e 100644 --- a/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Feature/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -57,7 +57,7 @@ public struct ProfileFeature: Sendable { @Shared(.userSession) var userSession @Presents var destination: Destination.State? - @Shared(.appStorage("editGeneration")) var editGeneration: Bool = false + @Shared(.isEditingGeneration) var editGeneration // 기존 TCA AlertState 유지 (다른 곳에서 사용) @Presents public var alert: AlertState? diff --git a/Projects/Feature/Profile/Tests/Sources/ProfileReducerAsyncFlowTests.swift b/Projects/Feature/Profile/Tests/Sources/ProfileReducerAsyncFlowTests.swift index 4ed66b48..6f8594d5 100644 --- a/Projects/Feature/Profile/Tests/Sources/ProfileReducerAsyncFlowTests.swift +++ b/Projects/Feature/Profile/Tests/Sources/ProfileReducerAsyncFlowTests.swift @@ -121,12 +121,9 @@ struct ProfileReducerAsyncFlowTests { @Test("기수 변경 중에는 기존 프로필을 다시 조회하지 않는다") func fetchUserWhileEditingGenerationIsIgnored() async { - let suiteName = "ProfileReducerAsyncFlowTests.\(UUID().uuidString)" - let appStorage = UserDefaults(suiteName: suiteName)! - defer { appStorage.removePersistentDomain(forName: suiteName) } - + let storage = InMemoryStorage() let initialState = withDependencies { - $0.defaultAppStorage = appStorage + $0.defaultInMemoryStorage = storage } operation: { var state = ProfileFeature.State() state.$editGeneration.withLock { $0 = true } @@ -136,7 +133,7 @@ struct ProfileReducerAsyncFlowTests { let store = TestStore(initialState: initialState) { ProfileFeature() } withDependencies: { - $0.defaultAppStorage = appStorage + $0.defaultInMemoryStorage = storage $0.profileUseCase = StubProfileUseCase( cachedProfile: nil, getProfileResult: .success(ProfileTestSupport.memberProfile) @@ -149,6 +146,28 @@ struct ProfileReducerAsyncFlowTests { await store.finish() } + @Test("이전 버전의 기수 변경 저장값이 남아도 캐시 없는 프로필을 서버에서 복구한다") + func legacyEditFlagDoesNotBlockProfileLoading() async { + let defaults = UserDefaults.inMemory + defaults.set(true, forKey: "editGeneration") + let profile = ProfileTestSupport.memberProfile + let store = TestStore(initialState: ProfileFeature.State()) { + ProfileFeature() + } withDependencies: { + $0.defaultAppStorage = defaults + $0.defaultInMemoryStorage = InMemoryStorage() + $0.profileUseCase = StubProfileUseCase(refreshProfileResult: .success(profile)) + } + + #expect(!store.state.editGeneration) + await store.send(.async(.fetchUser)) + await store.receive(\.inner.fetchUserResponse) { + $0.viewState = .loaded + $0.profile = profile + } + await store.finish() + } + @Test("캐시가 없는 업데이트 사용자도 실패 후 재시도로 프로필을 복구한다") func retryAfterColdCacheFailureLoadsProfile() async { let store = TestStore(initialState: ProfileFeature.State()) { diff --git a/Projects/Feature/Profile/Tests/Sources/ProfileReducerRoutingTests.swift b/Projects/Feature/Profile/Tests/Sources/ProfileReducerRoutingTests.swift index 80fcf954..8835b4e4 100644 --- a/Projects/Feature/Profile/Tests/Sources/ProfileReducerRoutingTests.swift +++ b/Projects/Feature/Profile/Tests/Sources/ProfileReducerRoutingTests.swift @@ -87,13 +87,10 @@ struct ProfileReducerRoutingTests { let store = TestStore(initialState: ProfileFeature.State()) { ProfileFeature() } - // editGeneration 은 appStorage 공유 상태라 초기값이 실행 순서에 따라 달라질 수 있다. - // 최종값만 확인해 실행 순서와 무관하게 통과하도록 둔다. - store.exhaustivity = .off - - await store.send(.delegate(.presentEditGeneration)) - - #expect(store.state.editGeneration == true) + await store.send(.delegate(.presentEditGeneration)) { + $0.$editGeneration.withLock { $0 = true } + $0.viewState = .failed + } } @Test("화면 이동만 알리는 위임 액션들은 상태를 바꾸지 않는다")