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 getLatestNotices() -> 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.getLatestNotices() 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"}]}"#) wait("unrelated notice response") { viewModel.latestNotices.first?.title == "Synthetic notice" } expect(viewModel.latestNotices.count == 1, "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)") } }