import Foundation import Combine #if EVENT_MOYA_DOUBLE public enum Method { case get } public enum Task { case requestPlain } public protocol TargetType { var baseURL: URL { get } var path: String { get } var method: Method { get } var task: Task { get } var headers: [String: String]? { get } } public struct Response { public let statusCode: Int public let data: Data public init(statusCode: Int, data: Data) { self.statusCode = statusCode self.data = data } } public enum MoyaError: Error { case simulatedTransportFailure } public enum EventTransport { public static var request: ((any TargetType) -> AnyPublisher)! } public final class MoyaProvider { public init() {} } #elseif EVENT_COMBINE_MOYA_DOUBLE import Moya extension MoyaProvider { public func requestPublisher(_ target: T) -> AnyPublisher { EventTransport.request(target) } } #elseif EVENT_ROUTE_CHECK @main struct EventRouteCheck { static func main() throws { let root = CommandLine.arguments[1] + "/SodaLive/Sources/" let step = try String(contentsOfFile: root + "App/AppStep.swift", encoding: .utf8) guard step.contains("case eventDetail(eventId: Int)") else { fputs("FAIL: event detail route still retains payload instead of Int ID\n", stderr) exit(1) } let callers = ["Settings/Event/EventListView.swift", "Main/EventPopupDialogView.swift", "Live/EventBanner/SectionEventBannerView.swift", "Content/Main/Banner/ContentMainBannerView.swift", "Content/Main/V2/ContentMainBannerViewV2.swift", "V2/Main/MainView.swift"] var count = 0 for file in callers { let source = try String(contentsOfFile: root + file, encoding: .utf8) let pattern = #"\.eventDetail\(\s*eventId:\s*[\w.!]+\.id\s*\)"# let matches = source.matches(of: try Regex(pattern)).count guard matches == (file == "V2/Main/MainView.swift" ? 2 : 1), !source.contains(".eventDetail(event:") else { fatalError("ID-only caller missing: \(file)") } count += matches } let destination = try String(contentsOfFile: root + "ContentView.swift", encoding: .utf8) guard destination.contains("EventDetailView(eventId: eventId)") else { fatalError("ID destination missing") } print("PASS: supplementary ID-only route/destination and \(count) existing navigation call sites") } } #else import Moya let BASE_URL = "https://synthetic.invalid" final class UserDefaults { static let standard = UserDefaults() private var values: [String: Any] = [:] func set(_ value: Any, forKey key: String) { values[key] = value } 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) } } enum I18n { enum Common { static let commonError = "commonError" } } @main struct EventDetailCheck { static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { if !condition() { fputs("FAIL: \(message)\n", stderr) exit(1) } } static func drainMainQueue() { var drained = false DispatchQueue.main.async { drained = true } let deadline = Date().addingTimeInterval(2) while !drained && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.001)) } expect(drained, "Main queue did not drain") } static func json(id: Int = 42, fields: String = #", "detailImageUrl":null,"popupImageUrl":null,"link":null"#) -> String { #"{"success":true,"data":{"id":\#(id),"thumbnailImageUrl":"https://synthetic.invalid/thumb"\#(fields)},"message":null,"errorProperty":null}"# } static func response(_ json: String, status: Int = 200) -> Response { Response(statusCode: status, data: Data(json.utf8)) } static func installTransport(_ subject: PassthroughSubject) { EventTransport.request = { target in expect(target.path == "/api/v2/events/42", "VM must use actual detail repository") return subject.eraseToAnyPublisher() } } static func checkAPI() { expect(Int.max == 9_223_372_036_854_775_807, "Supported Int must be signed 64-bit") let repository = EventRepository() for token in ["", "synthetic-token"] { UserDefaults.set(token, forKey: .token) var paths: [String] = [] EventTransport.request = { target in paths.append(target.path) expect(target.baseURL.absoluteString == BASE_URL && target.method == .get, "Base URL or GET changed") guard case .requestPlain = target.task else { fatalError("No body/query permitted") } let legacy = target.path == "/event" || target.path == "/event/popup" expect(target.headers?["Authorization"] == (legacy || !token.isEmpty ? "Bearer \(token)" : nil), "Detail optional auth or legacy headers changed") return Empty().eraseToAnyPublisher() } _ = repository.getEventDetail(id: 42) _ = repository.getEventDetail(id: 4_294_967_296) _ = repository.getEventDetail(id: Int.max) _ = repository.getEvents() _ = repository.getEventPopup() expect(paths == ["/api/v2/events/42", "/api/v2/events/4294967296", "/api/v2/events/9223372036854775807", "/event", "/event/popup"], "Repository paths/64-bit ID changed") } print("PASS: actual API/repository exact GET/requestPlain, anonymous/bearer, legacy routes/headers, >32-bit and Int.max") } static func checkSuccess() { for id in [42, 4_294_967_296, Int.max] { let subject = PassthroughSubject() var requests = 0 EventTransport.request = { target in requests += 1 expect(target.path == "/api/v2/events/\(id)", "ID truncated") return subject.eraseToAnyPublisher() } let model = EventDetailViewModel(eventId: id) var states: [EventDetailViewModel.State] = [] let observation = model.$state.dropFirst().sink { expect(Thread.isMainThread, "State must publish on main") states.append($0) } model.load() model.load() expect(model.isLoading && model.state == .loading && requests == 1, "Loading must deduplicate") let sent = DispatchSemaphore(value: 0) DispatchQueue.global().async { subject.send(response(json(id: id))) subject.send(completion: .finished) sent.signal() } expect(sent.wait(timeout: .now() + 2) == .success, "Background response timed out") drainMainQueue() guard case .loaded(let item) = model.state else { fatalError("Object must load") } expect(item.id == id && item.thumbnailImageUrl == "https://synthetic.invalid/thumb", "Production DTO data lost") expect(item.detailImageUrl == nil && item.popupImageUrl == nil && item.link == nil, "Null optionals changed") expect(!model.isLoading && !model.isShowPopup && states.count == 2, "Loading or success publication wrong") model.cancel() model.load() expect(requests == 1, "Same loaded screen must not refetch on reappearance") withExtendedLifetime(observation) {} } for fields in ["", #", "detailImageUrl":"https://synthetic.invalid/detail""#, #", "link":"https://synthetic.invalid/join""#, #", "detailImageUrl":"https://synthetic.invalid/detail","link":"http://synthetic.invalid/join""#] { let subject = PassthroughSubject() installTransport(subject) let model = EventDetailViewModel(eventId: 42) model.load() subject.send(response(json(fields: fields))) subject.send(completion: .finished) drainMainQueue() guard case .loaded(let item) = model.state else { fatalError("Image-only/link-only/both/neither must load") } expect((model.participationURL != nil) == (item.link != nil), "Optional link must be independent of image") } print("PASS: actual DTO/VM single object, optional omitted/null, image-only/link-only/both/neither, 64-bit IDs, main delivery, no refetch") } static func checkUnavailableAndErrors() { for id in [0, -1, Int.min] { EventTransport.request = { _ in fatalError("Nonpositive ID must not request") } let model = EventDetailViewModel(eventId: id) model.load() model.load() expect(model.state == .unavailable && !model.isLoading, "Invalid ID unavailable/loading") expect(model.takeUnavailableDismissal() && !model.takeUnavailableDismissal(), "Invalid ID must dismiss once") } for data in ["null", "[]", "{}", #"{"id":"bad"}"#] { let subject = PassthroughSubject() installTransport(subject) let model = EventDetailViewModel(eventId: 42) var pops = 0 let observation = model.$state.sink { state in if state == .unavailable && model.takeUnavailableDismissal() { pops += 1 } } model.load() let value = response(#"{"success":false,"data":\#(data),"message":"다른 언어의 메시지"}"#) subject.send(value) subject.send(value) subject.send(completion: .finished) drainMainQueue() model.load() expect(model.state == .unavailable && !model.isLoading && pops == 1, "False must ignore data shape/message and dismiss once") expect(!model.isShowPopup, "Unavailable must not become response-error toast") withExtendedLifetime(observation) {} } let failures: [(String, Int)] = [ (#"{"success":true}"#, 200), (#"{"success":true,"data":null}"#, 200), (#"{"success":true,"data":[]}"#, 200), (#"{"success":true,"data":{}}"#, 200), (#"{"success":true,"data":{"id":42}}"#, 200), (#"{"success":true,"data":{"id":42,"thumbnailImageUrl":null}}"#, 200), (#"{"success":true,"data":{"id":42,"thumbnailImageUrl":7}}"#, 200), (json(id: 0), 200), (json(id: -1), 200), (json(id: 43), 200), (json(fields: #", "link":[]"#), 200), ("broken-json", 200), ("unavailable", 503), (json(), 503), (#"{"success":false,"data":null}"#, 503), (#"{"success":false,"data":null}"#, 401), (json(), 401), (json(), 201) ] for (payload, status) in failures { let subject = PassthroughSubject() installTransport(subject) let model = EventDetailViewModel(eventId: 42) model.load() subject.send(response(payload, status: status)) subject.send(completion: .finished) drainMainQueue() expect(model.state == .error && !model.isLoading && model.isShowPopup, "HTTP \(status)/malformed success must be error") expect(model.errorMessage == I18n.Common.commonError && !model.takeUnavailableDismissal(), "Error must not pop/classify unavailable") } for transportFailure in [true, false] { let subject = PassthroughSubject() installTransport(subject) let model = EventDetailViewModel(eventId: 42) model.load() subject.send(completion: transportFailure ? .failure(.simulatedTransportFailure) : .finished) drainMainQueue() expect(model.state == .error && !model.isLoading && model.isShowPopup && !model.takeUnavailableDismissal(), "Transport/empty completion must terminate loading without pop") } print("PASS: nonpositive no request; false ignores malformed data/localized message; malformed success/HTML503/abnormal HTTP/401/transport stay error, loading ends, one dismissal") } static func checkLinksAndCancellation() { for link in ["https://synthetic.invalid/join", "http://synthetic.invalid/join", "HTTPS://synthetic.invalid/join", "", " ", "/join", "//synthetic.invalid/join", "https:///", "https://", "not a URL", "javascript:alert(1)", "data:text/plain,test", "file:///tmp/test", "sodalive://join", "mailto:a@example.invalid"] { let subject = PassthroughSubject() installTransport(subject) let model = EventDetailViewModel(eventId: 42) model.load() let encoded = String(data: try! JSONEncoder().encode(link), encoding: .utf8)! subject.send(response(json(fields: ",\"link\":\(encoded)"))) drainMainQueue() guard case .loaded = model.state else { fatalError("Invalid link must not invalidate detail") } let allowed = ["https://synthetic.invalid/join", "http://synthetic.invalid/join", "HTTPS://synthetic.invalid/join"].contains(link) expect((model.participationURL != nil) == allowed, "Unsafe URL accepted or HTTP(S) rejected: \(link)") } let oldSubject = PassthroughSubject() let newSubject = PassthroughSubject() var requests = 0 var cancels = 0 EventTransport.request = { _ in requests += 1 return (requests == 1 ? oldSubject : newSubject) .handleEvents(receiveCancel: { cancels += 1 }).eraseToAnyPublisher() } let model = EventDetailViewModel(eventId: 42) model.load() oldSubject.send(response(#"{"success":false,"data":null}"#)) model.cancel() expect(!model.isLoading && model.state == .idle && cancels == 1, "Disappearance must cancel/reset pending request") oldSubject.send(response(json())) drainMainQueue() expect(model.state == .idle && !model.takeUnavailableDismissal(), "Queued/late response after disappearance must not change/pop") model.load() oldSubject.send(response(#"{"success":false,"data":null}"#)) newSubject.send(response(json())) newSubject.send(response(#"{"success":false,"data":null}"#)) drainMainQueue() guard case .loaded = model.state else { fatalError("Only new request's first response may win") } expect(requests == 2 && !model.takeUnavailableDismissal(), "Duplicate/old response must not pop another screen") let unavailableSubject = PassthroughSubject() installTransport(unavailableSubject) let unavailable = EventDetailViewModel(eventId: 42) unavailable.load() unavailableSubject.send(response(#"{"success":false,"data":null}"#)) drainMainQueue() unavailable.cancel() expect(!unavailable.takeUnavailableDismissal(), "Disappeared unavailable view must not later dismiss another screen") print("PASS: actual participation URL HTTP(S)+host boundary; cancel/queued/late/stale/duplicate responses, reload after cancellation, disappearance suppresses dismissal") } static func main() { checkAPI() checkSuccess() checkUnavailableAndErrors() checkLinksAndCancellation() print("PASS: Event detail regression checks (actual production API/repository/VM/DTO, native Combine; no server calls)") } } #endif