diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index 8ee33a17..6fbd8dc1 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -202,7 +202,7 @@ struct ChatTabView: View { ) } } - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) } } diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift index a752794b..fb4e6a8c 100644 --- a/SodaLive/Sources/Home/HomeTabView.swift +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -505,7 +505,7 @@ struct HomeTabView: View { } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) } } diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 79c87707..152f4849 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -1639,20 +1639,40 @@ enum I18n { enum Content { static var authSensitiveContentOnDescription: String { pick( - ko: "성인인증 완료 후 성인 콘텐츠를 볼 수 있어요", - en: "You can view adult content after age verification.", - ja: "年齢認証完了後、成人向けコンテンツを表示できます" + ko: "본인인증 완료 후 민감한 콘텐츠를 볼 수 있어요", + en: "You can view sensitive content after identity verification.", + ja: "本人認証完了後、センシティブなコンテンツを表示できます" ) } static var authSensitiveContentOffDescription: String { pick( - ko: "성인 콘텐츠가 목록과 검색에 노출되지 않아요", - en: "Adult content will not appear in lists or search.", - ja: "成人向けコンテンツは一覧や検索に表示されません" + ko: "민감한 콘텐츠가 목록과 검색에 노출되지 않아요", + en: "Sensitive content will not appear in lists or search.", + ja: "センシティブなコンテンツは一覧や検索に表示されません" ) } + static var authExitTitle: String { + pick(ko: "본인인증 종료", en: "End identity verification", ja: "本人認証の終了") + } + + static var authExitMessage: String { + pick( + ko: "본인인증을 종료하시겠어요?\n종료하면 다시 본인인증을 진행해야 해요.", + en: "Do you want to end identity verification?\nIf you end it, you’ll need to verify your identity again.", + ja: "本人認証を終了しますか?\n終了すると、もう一度本人認証を行う必要があります。" + ) + } + + static var authExitConfirm: String { + pick(ko: "종료하기", en: "End verification", ja: "終了する") + } + + static var authExitCancel: String { + pick(ko: "계속하기", en: "Continue", ja: "続ける") + } + static var preferredContent: String { pick(ko: "선호 콘텐츠", en: "Preferred content", ja: "好みのコンテンツ") } diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift index 9c0c342d..5c4822fc 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift @@ -151,7 +151,7 @@ struct LiveNowAllView: View { ) } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) } private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) { diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index 348525b8..5a87169b 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -459,7 +459,7 @@ struct HomeView: View { autohideIn: 2 ) } - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) } private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) { diff --git a/SodaLive/Sources/MyPage/MyPageViewModel.swift b/SodaLive/Sources/MyPage/MyPageViewModel.swift index d12a79b3..0caadcfb 100644 --- a/SodaLive/Sources/MyPage/MyPageViewModel.swift +++ b/SodaLive/Sources/MyPage/MyPageViewModel.swift @@ -119,6 +119,19 @@ final class MyPageViewModel: ObservableObject { isShowAuthContentSettings = true } + func cancelAuthContentSettings() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.cancelAuthContentSettings() + } + return + } + guard !isSubmittingAuth, isShowAuthContentSettings else { return } + + clearPendingAuthVerification() + isShowPopup = false + } + func confirmAuthContentSettings() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in diff --git a/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift b/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift index 1e5e3181..8cdade0c 100644 --- a/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift +++ b/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift @@ -21,9 +21,12 @@ struct AuthContentSettingsSheet: View { @Binding var isAdultContentVisible: Bool @Binding var contentType: ContentType let onConfirm: () -> Void + let onClose: () -> Void var body: some View { VStack(spacing: 0) { + header + ViewThatFits(in: .vertical) { settingsContent .fixedSize(horizontal: false, vertical: true) @@ -49,17 +52,42 @@ struct AuthContentSettingsSheet: View { .fixedSize(horizontal: false, vertical: true) } .background(Color.gray900) + .accessibilityAction(.escape, onClose) } - private var settingsContent: some View { - VStack(spacing: 0) { + private var header: some View { + HStack(spacing: 0) { + Color.clear + .frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight) + .accessibilityHidden(true) + Text(I18n.Settings.contentViewSettings) .appFont(size: SodaTypography.heading4.size, weight: .medium) .foregroundColor(.white) .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.headerHeight) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: SodaTypography.body2.size, weight: .medium)) + .foregroundColor(.gray400) + .frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityLabel(I18n.Settings.Content.authExitTitle) + .accessibilityIdentifier("authContentSettings.close") + } + .frame(minHeight: AuthContentSettingsLayout.headerHeight) + .padding(.top, AuthContentSettingsLayout.topInset) + .padding(.horizontal, SodaSpacing.s14) + .fixedSize(horizontal: false, vertical: true) + } + + private var settingsContent: some View { + VStack(spacing: 0) { sensitiveContentRow .padding(.top, SodaSpacing.s8) .padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8) @@ -83,7 +111,6 @@ struct AuthContentSettingsSheet: View { .padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8) .padding(.bottom, SodaSpacing.s20) } - .padding(.top, AuthContentSettingsLayout.topInset) .padding(.horizontal, SodaSpacing.s14) } @@ -193,9 +220,12 @@ private struct AuthContentSettingsToggleStyle: ToggleStyle { private struct AuthContentSettingsModifier: ViewModifier { @ObservedObject var viewModel: MyPageViewModel + let onCancel: () -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isHostPresented = false @State private var panelProgress: CGFloat = 0 + @State private var isExitDialogPresented = false + @AccessibilityFocusState private var isExitDialogFocused: Bool private var callerErrorIsPresented: Binding { Binding( @@ -214,6 +244,7 @@ private struct AuthContentSettingsModifier: ViewModifier { } .sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2) .fullScreenCover(isPresented: $isHostPresented, onDismiss: { + dismissExitDialog() panelProgress = 0 viewModel.submitPendingAuthVerification() }) { @@ -225,7 +256,8 @@ private struct AuthContentSettingsModifier: ViewModifier { AuthContentSettingsSheet( isAdultContentVisible: $viewModel.authIsAdultContentVisible, contentType: $viewModel.authContentType, - onConfirm: viewModel.confirmAuthContentSettings + onConfirm: viewModel.confirmAuthContentSettings, + onClose: showExitDialog ) .padding(.leading, proxy.safeAreaInsets.leading) .padding(.trailing, proxy.safeAreaInsets.trailing) @@ -241,6 +273,8 @@ private struct AuthContentSettingsModifier: ViewModifier { } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .disabled(isExitDialogPresented) + .accessibilityHidden(isExitDialogPresented) .modifier(AuthContentSettingsPanelMotion( progress: panelProgress, reduceMotion: reduceMotion, @@ -249,6 +283,11 @@ private struct AuthContentSettingsModifier: ViewModifier { } .ignoresSafeArea(.container, edges: [.horizontal, .bottom]) } + .overlay { + if isExitDialogPresented { + exitDialog + } + } .interactiveDismissDisabled(true) .presentationBackground(.clear) .onAppear { @@ -256,6 +295,7 @@ private struct AuthContentSettingsModifier: ViewModifier { updatePanel() } } + .onDisappear(perform: dismissExitDialog) } .onAppear { if viewModel.isShowAuthContentSettings { @@ -263,6 +303,9 @@ private struct AuthContentSettingsModifier: ViewModifier { } } .onChange(of: viewModel.isShowAuthContentSettings) { isPresented in + if !isPresented { + dismissExitDialog() + } if isPresented && !isHostPresented { setHostPresented(true) } else if isHostPresented { @@ -276,6 +319,45 @@ private struct AuthContentSettingsModifier: ViewModifier { } } + private var exitDialog: some View { + SodaV2ActionModal( + title: I18n.Settings.Content.authExitTitle, + message: I18n.Settings.Content.authExitMessage, + button1: SodaV2ActionModalButton( + label: I18n.Settings.Content.authExitConfirm, + action: confirmExit + ), + button2: SodaV2ActionModalButton( + label: I18n.Settings.Content.authExitCancel, + action: dismissExitDialog + ), + onDimmedTap: dismissExitDialog + ) + .accessibilityElement(children: .contain) + .accessibilityFocused($isExitDialogFocused) + .accessibilityAction(.escape, dismissExitDialog) + .onAppear { + isExitDialogFocused = true + } + } + + private func showExitDialog() { + guard isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return } + isExitDialogPresented = true + } + + private func dismissExitDialog() { + isExitDialogPresented = false + isExitDialogFocused = false + } + + private func confirmExit() { + guard isExitDialogPresented, isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return } + dismissExitDialog() + viewModel.cancelAuthContentSettings() + onCancel() + } + private func setHostPresented(_ isPresented: Bool) { var transaction = Transaction() transaction.disablesAnimations = true @@ -323,19 +405,19 @@ private struct AuthContentSettingsPanelMotion: GeometryEffect { } extension View { - func authContentSettings(viewModel: MyPageViewModel) -> some View { - modifier(AuthContentSettingsModifier(viewModel: viewModel)) + func authContentSettings(viewModel: MyPageViewModel, onCancel: @escaping () -> Void = {}) -> some View { + modifier(AuthContentSettingsModifier(viewModel: viewModel, onCancel: onCancel)) } } struct AuthContentSettingsSheet_Previews: PreviewProvider { static var previews: some View { Group { - AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}) + AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}, onClose: {}) .frame(width: 402) .previewDisplayName("ON / ALL") - AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}) + AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}, onClose: {}) .frame(width: 402) .previewDisplayName("OFF / ALL") } diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 46bd7323..b34c9cd0 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -258,7 +258,7 @@ struct CreatorChannelView: View { .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { authView } - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) .onAppear { configurePayload() if viewModel.hasLoaded == false { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index ef505510..3d6c66e9 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -174,7 +174,7 @@ struct MainView: View { autohideIn: 2 ) } - .authContentSettings(viewModel: mypageViewModel) + .authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil }) } private var shouldShowCreatorActionMenu: Bool { diff --git a/work/tests/AuthContentSettingsExitCheck.swift b/work/tests/AuthContentSettingsExitCheck.swift new file mode 100644 index 00000000..54acb0cd --- /dev/null +++ b/work/tests/AuthContentSettingsExitCheck.swift @@ -0,0 +1,372 @@ +import Foundation +import Combine + +struct Response { + let statusCode: Int + let data: Data +} + +enum MoyaError: Error { + case simulatedTransportFailure +} + +enum AuthExitIO { + static var requests: [AuthVerifyRequest] = [] + static var authSubjects: [PassthroughSubject] = [] + static var memberSubjects: [PassthroughSubject] = [] + static var noticeSubjects: [PassthroughSubject] = [] +} + +final class AuthRepository { + func authCertificate(request: AuthVerifyRequest) -> AnyPublisher { + precondition(Thread.isMainThread) + AuthExitIO.requests.append(request) + let subject = PassthroughSubject() + AuthExitIO.authSubjects.append(subject) + return subject.eraseToAnyPublisher() + } +} + +final class UserRepository { + func getMypage() -> AnyPublisher { + fatalError("Unexpected My Page request in auth exit check") + } + + func getMemberInfo() -> AnyPublisher { + precondition(Thread.isMainThread) + let subject = PassthroughSubject() + AuthExitIO.memberSubjects.append(subject) + return subject.eraseToAnyPublisher() + } +} + +final class NoticeRepository { + func getLatestNotice() -> AnyPublisher { + let subject = PassthroughSubject() + AuthExitIO.noticeSubjects.append(subject) + return subject.eraseToAnyPublisher() + } +} + +enum AppStep { + case splash +} + +final class AppState { + static let shared = AppState() + var isRestartApp = false + var restartCount = 0 + + func setAppStep(step: AppStep) { + precondition(Thread.isMainThread) + restartCount += 1 + } +} + +final class UserDefaults { + static let standard = UserDefaults() + var values: [String: Any] = [:] + var writes: [String] = [] + + func set(_ value: Any, forKey key: String) { + precondition(Thread.isMainThread) + values[key] = value + writes.append(key) + } + + func object(forKey key: String) -> Any? { values[key] } + func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } + func string(forKey key: String) -> String? { values[key] as? String } + func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } + func data(forKey key: String) -> Data? { values[key] as? Data } + func removeObject(forKey key: String) { + values.removeValue(forKey: key) + writes.append(key) + } +} + +enum I18n { + enum Common { + static let commonError = "commonError" + } + enum Main { + enum Auth { + static let authenticationError = "authenticationError" + } + } + enum MyPage { + enum Auth { + static let verificationErrorWithSupport = "verificationErrorWithSupport" + } + } +} + +func DEBUG_LOG(_ message: String) {} +func ERROR_LOG(_ message: String) {} + +private final class CallbackLifetime {} + +@main +struct AuthContentSettingsExitCheck { + static let authSuccess = #"{"success":true,"data":{"gender":0}}"# + static let memberSuccess = #"{"success":true,"data":{"can":13,"point":21,"isAuth":true,"gender":null,"signupDate":"synthetic-date","chargeCount":2,"role":"CREATOR","messageNotice":false,"followingChannelLiveNotice":true,"followingChannelUploadContentNotice":false,"auditionNotice":true,"countryCode":"KR","isAdultContentVisible":true,"contentType":"FEMALE"}}"# + static let persistedSettings: [String: Any] = [ + "can": 3, "point": 5, "auth": false, "role": "USER", + "isAuditionNotification": false, "countryCode": "JP", + "isAdultContentVisible": false, "contentPreference": "MALE" + ] + + static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("FAIL: \(message)\n", stderr) + exit(1) + } + } + + static func wait(_ message: String, until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(2) + while !condition() && Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.001)) + } + expect(condition(), "Timed out: \(message)") + } + + static func drainMainQueue() { + var drained = false + DispatchQueue.main.async { drained = true } + wait("main queue drain") { drained } + } + + static func reset() { + AuthExitIO.requests = [] + AuthExitIO.authSubjects = [] + AuthExitIO.memberSubjects = [] + AuthExitIO.noticeSubjects = [] + UserDefaults.standard.values = persistedSettings + UserDefaults.standard.writes = [] + AppState.shared.isRestartApp = false + AppState.shared.restartCount = 0 + } + + static func receipt(_ identifier: String) -> [String: Any] { + ["data": ["receipt_id": identifier]] + } + + static func begin(onSuccess: (() -> Void)? = nil) -> MyPageViewModel { + let viewModel = MyPageViewModel() + viewModel.authVerify(receipt("synthetic-old-receipt"), onSuccess: onSuccess) + viewModel.presentAuthContentSettings() + expect(viewModel.isShowAuthContentSettings, "Valid receipt must open settings") + return viewModel + } + + static func send(_ subject: PassthroughSubject, json: String, status: Int = 200) { + subject.send(Response(statusCode: status, data: Data(json.utf8))) + subject.send(completion: .finished) + } + + static func expectNoEffects(posts: Int, _ context: String) { + expect(AuthExitIO.requests.count == posts, "\(context): unexpected auth POST") + expect(AuthExitIO.memberSubjects.isEmpty, "\(context): unexpected member GET") + expect(UserDefaults.standard.writes.isEmpty, "\(context): persisted settings changed") + expect(NSDictionary(dictionary: UserDefaults.standard.values).isEqual(to: persistedSettings), "\(context): existing settings lost") + expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "\(context): unexpected restart") + } + + static func exitAndDismiss(_ viewModel: MyPageViewModel, posts: Int, context: String) { + viewModel.cancelAuthContentSettings() + expect(!viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth, "\(context): exit must close idle panel") + expect(!viewModel.isShowPopup, "\(context): old error toast leaked") + viewModel.cancelAuthContentSettings() + viewModel.submitPendingAuthVerification() + viewModel.submitPendingAuthVerification() + viewModel.presentAuthContentSettings() + viewModel.confirmAuthContentSettings() + viewModel.submitPendingAuthVerification() + drainMainQueue() + expect(!viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth, "\(context): discarded receipt was reused") + expectNoEffects(posts: posts, context) + } + + static func completeFreshAuthentication(_ viewModel: MyPageViewModel, context: String) { + let priorPosts = AuthExitIO.requests.count + var freshCallbackCount = 0 + viewModel.authVerify(receipt("synthetic-fresh-receipt")) { + expect(Thread.isMainThread, "\(context): callback must run on main") + expect(!viewModel.isSubmittingAuth && !viewModel.isShowAuthContentSettings, "\(context): callback preceded flow cleanup") + expect(UserDefaults.standard.writes.count == 8, "\(context): callback preceded member persistence") + expect(UserDefaults.int(forKey: .can) == 13 && UserDefaults.int(forKey: .point) == 21, "\(context): member balance not synchronized") + expect(UserDefaults.bool(forKey: .auth) && viewModel.isAuth, "\(context): auth not synchronized") + expect(UserDefaults.string(forKey: .role) == "CREATOR" && UserDefaults.bool(forKey: .isAuditionNotification), "\(context): member role/notification not synchronized") + expect(UserDefaults.string(forKey: .countryCode) == "KR", "\(context): member country not synchronized") + expect(UserDefaults.bool(forKey: .isAdultContentVisible) && UserDefaults.string(forKey: .contentPreference) == "FEMALE", "\(context): member content not synchronized") + freshCallbackCount += 1 + } + viewModel.presentAuthContentSettings() + expect(viewModel.isShowAuthContentSettings && viewModel.authIsAdultContentVisible && viewModel.authContentType == .ALL, "\(context): fresh receipt must start ON/ALL") + expect(!viewModel.isShowPopup, "\(context): fresh flow inherited old toast") + viewModel.confirmAuthContentSettings() + expectNoEffects(posts: priorPosts, "\(context): before host dismissal") + viewModel.submitPendingAuthVerification() + viewModel.submitPendingAuthVerification() + expect(AuthExitIO.requests.count == priorPosts + 1, "\(context): fresh POST must occur once") + let request = AuthExitIO.requests.last! + expect(request.receiptId == "synthetic-fresh-receipt" && request.isAdultContentVisible && request.contentType == .ALL, "\(context): stale receipt/draft submitted") + send(AuthExitIO.authSubjects.last!, json: authSuccess) + wait("\(context): member GET") { AuthExitIO.memberSubjects.count == 1 } + expect(freshCallbackCount == 0 && UserDefaults.standard.writes.isEmpty, "\(context): callback/persistence preceded GET completion") + send(AuthExitIO.memberSubjects[0], json: memberSuccess) + wait("\(context): fresh callback") { freshCallbackCount == 1 } + viewModel.submitPendingAuthVerification() + viewModel.presentAuthContentSettings() + viewModel.cancelAuthContentSettings() + drainMainQueue() + expect(freshCallbackCount == 1 && AuthExitIO.requests.count == priorPosts + 1 && AuthExitIO.memberSubjects.count == 1, "\(context): completed flow repeated") + expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "\(context): successful callback route restarted") + } + + static func checkInitialExit() { + for callbackRoute in [false, true] { + reset() + var oldCallbackCount = 0 + var lifetime: CallbackLifetime? = CallbackLifetime() + weak var retainedLifetime = lifetime + let viewModel = begin(onSuccess: callbackRoute ? { [owner = lifetime!] in + withExtendedLifetime(owner) { oldCallbackCount += 1 } + } : nil) + lifetime = nil + viewModel.authIsAdultContentVisible = false + viewModel.isLoading = true + viewModel.isShowPopup = true + exitAndDismiss(viewModel, posts: 0, context: "initial exit") + expect(retainedLifetime == nil, "Initial exit retained old success closure") + expect(viewModel.isLoading, "Cancellation changed unrelated loading state") + completeFreshAuthentication(viewModel, context: "initial re-entry") + expect(oldCallbackCount == 0, "Initial exit executed old callback") + } + print("PASS: initial exit (My Page/callback), repeat exit/host dismissal have no effects; fresh ON/ALL and fresh callback once after sync") + } + + static func checkFailureExit() { + let failures: [(String, String?, Int)] = [ + ("transport", nil, 200), + ("HTTP", authSuccess, 503), + ("envelope", #"{"success":false,"data":null}"#, 200), + ("decode", "invalid-json", 200), + ("missing data", #"{"success":true,"data":null}"#, 200), + ("invalid DTO", #"{"success":true,"data":{}}"#, 200) + ] + for (name, json, status) in failures { + reset() + var oldCallbackCount = 0 + let viewModel = begin { oldCallbackCount += 1 } + viewModel.authContentType = .FEMALE + viewModel.confirmAuthContentSettings() + viewModel.submitPendingAuthVerification() + expect(AuthExitIO.requests.count == 1, "\(name): expected one attempted POST") + if let json = json { + send(AuthExitIO.authSubjects[0], json: json, status: status) + } else { + AuthExitIO.authSubjects[0].send(completion: .failure(.simulatedTransportFailure)) + } + wait("\(name): failure panel") { viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth } + expect(viewModel.isShowPopup && viewModel.authIsAdultContentVisible && viewModel.authContentType == .FEMALE, "\(name): failure did not reopen same draft with error") + exitAndDismiss(viewModel, posts: 1, context: "\(name) failure exit") + completeFreshAuthentication(viewModel, context: "\(name) failure re-entry") + expect(oldCallbackCount == 0, "\(name): old callback executed after cancellation") + print("PASS: \(name) failure reopens same draft; exit adds no effects; fresh receipt/callback succeeds") + } + } + + static func checkSubmissionGuards() { + reset() + var callbackCount = 0 + let viewModel = begin { callbackCount += 1 } + viewModel.authContentType = .MALE + viewModel.confirmAuthContentSettings() + viewModel.cancelAuthContentSettings() + expect(viewModel.isSubmittingAuth && !viewModel.isShowAuthContentSettings, "Cancel after confirm discarded pending submission") + expectNoEffects(posts: 0, "confirmed before host dismissal") + viewModel.submitPendingAuthVerification() + viewModel.cancelAuthContentSettings() + viewModel.confirmAuthContentSettings() + viewModel.submitPendingAuthVerification() + expect(viewModel.isSubmittingAuth && AuthExitIO.requests.count == 1, "Cancel during POST changed submission or duplicated POST") + expect(AuthExitIO.requests[0].contentType == .MALE, "Cancel after confirm lost request draft") + send(AuthExitIO.authSubjects[0], json: authSuccess) + wait("guard check member GET") { AuthExitIO.memberSubjects.count == 1 } + viewModel.cancelAuthContentSettings() + viewModel.submitPendingAuthVerification() + expect(viewModel.isSubmittingAuth && callbackCount == 0 && UserDefaults.standard.writes.isEmpty, "Cancel during GET interrupted synchronization") + send(AuthExitIO.memberSubjects[0], json: memberSuccess) + wait("guard check callback") { callbackCount == 1 } + drainMainQueue() + expect(AuthExitIO.requests.count == 1 && AuthExitIO.memberSubjects.count == 1 && callbackCount == 1, "Submission guard flow repeated") + expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "Submission guard flow restarted") + print("PASS: cancel ignored after confirm/before dismissal, during POST and during GET; original callback completes once") + } + + static func checkMyPageSave() { + reset() + let viewModel = begin() + viewModel.authContentType = .FEMALE + viewModel.confirmAuthContentSettings() + viewModel.cancelAuthContentSettings() + expectNoEffects(posts: 0, "My Page before dismissal") + viewModel.submitPendingAuthVerification() + viewModel.submitPendingAuthVerification() + expect(AuthExitIO.requests.count == 1, "My Page save POST must occur once after dismissal") + send(AuthExitIO.authSubjects[0], json: authSuccess) + wait("My Page restart") { AppState.shared.restartCount == 1 } + viewModel.cancelAuthContentSettings() + viewModel.submitPendingAuthVerification() + drainMainQueue() + expect(AuthExitIO.requests.count == 1 && AuthExitIO.memberSubjects.isEmpty, "My Page save added POST/GET") + expect(UserDefaults.standard.writes == ["isAdultContentVisible", "contentPreference"], "My Page save persistence changed") + expect(UserDefaults.bool(forKey: .isAdultContentVisible) && UserDefaults.string(forKey: .contentPreference) == "FEMALE", "My Page save lost confirmed settings") + expect(!viewModel.isSubmittingAuth && AppState.shared.isRestartApp && AppState.shared.restartCount == 1, "My Page save restart contract changed") + print("PASS: existing My Page save POST once after dismissal, confirmed settings persisted, restart once without GET") + } + + static func checkMainThreadAndHiddenPanel() { + reset() + let viewModel = MyPageViewModel() + viewModel.authVerify(receipt("synthetic-hidden-receipt")) + viewModel.isShowPopup = true + viewModel.cancelAuthContentSettings() + expect(viewModel.isShowPopup, "Hidden-panel cancel changed unrelated toast") + viewModel.presentAuthContentSettings() + expect(viewModel.isShowAuthContentSettings, "Hidden-panel cancel discarded receipt") + viewModel.getLatestNotice() + var cancellationOnMain: Bool? + let observation = viewModel.$isShowAuthContentSettings.dropFirst().sink { shown in + if !shown { cancellationOnMain = Thread.isMainThread } + } + let dispatched = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + viewModel.cancelAuthContentSettings() + dispatched.signal() + } + expect(dispatched.wait(timeout: .now() + 2) == .success, "Background cancellation did not return") + wait("background cancellation") { cancellationOnMain != nil } + expect(cancellationOnMain == true, "Background cancellation mutated panel off main") + send(AuthExitIO.noticeSubjects[0], json: #"{"success":true,"data":{"title":"Synthetic notice","content":"Synthetic content","date":"synthetic-date"}}"#) + expect(viewModel.latestNotice?.title == "Synthetic notice", "Cancellation removed unrelated shared subscriptions") + viewModel.submitPendingAuthVerification() + drainMainQueue() + expectNoEffects(posts: 0, "background exit") + expect(!viewModel.isShowPopup, "Background exit left toast visible") + withExtendedLifetime(observation) {} + print("PASS: hidden-panel cancellation ignored; background cancellation dispatches to main and preserves unrelated subscriptions") + } + + static func main() { + expect(Thread.isMainThread, "Harness must run on main thread") + checkInitialExit() + checkFailureExit() + checkSubmissionGuards() + checkMyPageSave() + checkMainThreadAndHiddenPanel() + print("PASS: actual MyPageViewModel/production DTO exit regression check (native Combine, isolated repository/AppState/UserDefaults doubles)") + } +} diff --git a/work/tests/check-auth-content-settings-exit.sh b/work/tests/check-auth-content-settings-exit.sh new file mode 100644 index 00000000..c4e336ec --- /dev/null +++ b/work/tests/check-auth-content-settings-exit.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \ + "$ROOT/SodaLive/Sources/MyPage/MyPageViewModel.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthResponse.swift" \ + "$ROOT/SodaLive/Sources/MyPage/MyPageResponse.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Content/ContentType.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + "$ROOT/work/tests/AuthContentSettingsExitCheck.swift" \ + -o "$TEMP_DIR/check-auth-content-settings-exit" +"$TEMP_DIR/check-auth-content-settings-exit"