Files
sodalive-ios/work/tests/AuthContentSettingsExitCheck.swift
T

374 lines
18 KiB
Swift

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<Response, MoyaError>] = []
static var memberSubjects: [PassthroughSubject<Response, MoyaError>] = []
static var noticeSubjects: [PassthroughSubject<Response, MoyaError>] = []
}
final class AuthRepository {
func authCertificate(request: AuthVerifyRequest) -> AnyPublisher<Response, MoyaError> {
precondition(Thread.isMainThread)
AuthExitIO.requests.append(request)
let subject = PassthroughSubject<Response, MoyaError>()
AuthExitIO.authSubjects.append(subject)
return subject.eraseToAnyPublisher()
}
}
final class UserRepository {
func getMypage() -> AnyPublisher<Response, MoyaError> {
fatalError("Unexpected My Page request in auth exit check")
}
func getMemberInfo() -> AnyPublisher<Response, MoyaError> {
precondition(Thread.isMainThread)
let subject = PassthroughSubject<Response, MoyaError>()
AuthExitIO.memberSubjects.append(subject)
return subject.eraseToAnyPublisher()
}
}
final class NoticeRepository {
func getLatestNotices() -> AnyPublisher<Response, MoyaError> {
let subject = PassthroughSubject<Response, MoyaError>()
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<Response, MoyaError>, 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)")
}
}