From 468a766242c4a81c440eef34cdde2e9b4c6cd518 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 29 Oct 2026 12:03:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(event):=20ID=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20API=EB=A5=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 22 ++ SodaLive/Sources/App/AppStep.swift | 2 +- .../Main/Banner/ContentMainBannerView.swift | 2 +- .../Main/V2/ContentMainBannerViewV2.swift | 2 +- SodaLive/Sources/ContentView.swift | 4 +- .../EventBanner/SectionEventBannerView.swift | 2 +- .../Sources/Main/EventPopupDialogView.swift | 2 +- .../Sources/Settings/Event/EventApi.swift | 11 +- .../Settings/Event/EventDetailView.swift | 78 +++-- .../Settings/Event/EventListView.swift | 2 +- .../Settings/Event/EventRepository.swift | 5 +- SodaLive/Sources/V2/Main/MainView.swift | 4 +- .../Settings/Event/EventDetailViewModel.swift | 114 +++++++ work/tests/EventDetailCheck.swift | 313 ++++++++++++++++++ work/tests/check-event-detail.sh | 30 ++ 15 files changed, 549 insertions(+), 44 deletions(-) create mode 100644 SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift create mode 100644 work/tests/EventDetailCheck.swift create mode 100644 work/tests/check-event-detail.sh diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index c8fcdad1..015c4748 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -658,6 +658,8 @@ 409142DA2A84CF4900EEBFB1 /* EventListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142D82A84CF4900EEBFB1 /* EventListView.swift */; }; 409142DC2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */; }; 409142DD2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */; }; + ED2911010000000000000001 /* EventDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2910010000000000000001 /* EventDetailViewModel.swift */; }; + ED2913010000000000000001 /* EventDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2910010000000000000001 /* EventDetailViewModel.swift */; }; 409142E02A84CF7400EEBFB1 /* TermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DF2A84CF7400EEBFB1 /* TermsView.swift */; }; 409142E12A84CF7400EEBFB1 /* TermsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142DF2A84CF7400EEBFB1 /* TermsView.swift */; }; 409142E32A84CFA300EEBFB1 /* TermsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409142E22A84CFA300EEBFB1 /* TermsViewModel.swift */; }; @@ -2527,6 +2529,7 @@ 409142D32A84CE3000EEBFB1 /* NoticeApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeApi.swift; sourceTree = ""; }; 409142D82A84CF4900EEBFB1 /* EventListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventListView.swift; sourceTree = ""; }; 409142DB2A84CF5800EEBFB1 /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; + ED2910010000000000000001 /* EventDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailViewModel.swift; sourceTree = ""; }; 409142DF2A84CF7400EEBFB1 /* TermsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsView.swift; sourceTree = ""; }; 409142E22A84CFA300EEBFB1 /* TermsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsViewModel.swift; sourceTree = ""; }; 409142E52A84CFC000EEBFB1 /* Terms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Terms.swift; sourceTree = ""; }; @@ -6677,6 +6680,7 @@ F18179AD98929A1E018058E0 /* V2 */ = { isa = PBXGroup; children = ( + ED2920010000000000000001 /* Settings */, AC5280043010280000A5BBA3 /* MyPage */, AC5200063012000000A5BBA3 /* Auth */, 976A594BED288A98C0EEA03C /* Component */, @@ -6687,6 +6691,22 @@ path = V2; sourceTree = ""; }; + ED2920010000000000000001 /* Settings */ = { + isa = PBXGroup; + children = ( + ED2920010000000000000002 /* Event */, + ); + path = Settings; + sourceTree = ""; + }; + ED2920010000000000000002 /* Event */ = { + isa = PBXGroup; + children = ( + ED2910010000000000000001 /* EventDetailViewModel.swift */, + ); + path = Event; + sourceTree = ""; + }; F4F9FC33C494BBFD8CE96749 /* Models */ = { isa = PBXGroup; children = ( @@ -7751,6 +7771,7 @@ 400EA77C2A89EDFE004992CF /* LiveNowAllView.swift in Sources */, 409143D52A85A64B00EEBFB1 /* ContentListViewModel.swift in Sources */, 409142DC2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */, + ED2911010000000000000001 /* EventDetailViewModel.swift in Sources */, 40B28AAA2A839D8F008BE463 /* SignUpRequest.swift in Sources */, 403085AA2D1188B700CB61DE /* GenerateUrlResponse.swift in Sources */, 409142342A848B5300EEBFB1 /* LiveRoomPasswordDialog.swift in Sources */, @@ -8692,6 +8713,7 @@ 400EA76C2A89C074004992CF /* GetLiveRoomTagResponse.swift in Sources */, 400EA77D2A89EDFE004992CF /* LiveNowAllView.swift in Sources */, 409142DD2A84CF5800EEBFB1 /* EventDetailView.swift in Sources */, + ED2913010000000000000001 /* EventDetailViewModel.swift in Sources */, 40B28AAB2A839D8F008BE463 /* SignUpRequest.swift in Sources */, 409142352A848B5300EEBFB1 /* LiveRoomPasswordDialog.swift in Sources */, 403085AB2D1188B700CB61DE /* GenerateUrlResponse.swift in Sources */, diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 759c3ba6..a9e021ef 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -34,7 +34,7 @@ enum AppStep { case events - case eventDetail(event: EventItem) + case eventDetail(eventId: Int) case terms diff --git a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift index de44e6e3..15834ff8 100644 --- a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift +++ b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift @@ -103,7 +103,7 @@ private struct ContentMainBannerPage: View { .onTapGesture { switch item.type { case .EVENT: - AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) + AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id)) case .CREATOR: AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) case .SERIES: diff --git a/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift b/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift index 6cdf67d9..606f8f6a 100644 --- a/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift +++ b/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift @@ -126,7 +126,7 @@ struct ContentMainBannerImageView: View { .onTapGesture { switch item.type { case .EVENT: - AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) + AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id)) case .CREATOR: AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) case .SERIES: diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 30a7ffa7..cf605961 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -146,8 +146,8 @@ struct AppStepLayerView: View { case .events: EventListView() - case .eventDetail(let event): - EventDetailView(event: event) + case .eventDetail(let eventId): + EventDetailView(eventId: eventId) case .terms: TermsView(isPrivacyPolicy: false) diff --git a/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift b/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift index 45137139..3b36382b 100644 --- a/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift +++ b/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift @@ -96,7 +96,7 @@ private struct SectionEventBannerPage: View { .onTapGesture { if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let _ = item.detailImageUrl { - AppState.shared.setAppStep(step: .eventDetail(event: item)) + AppState.shared.setAppStep(step: .eventDetail(eventId: item.id)) } else if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), diff --git a/SodaLive/Sources/Main/EventPopupDialogView.swift b/SodaLive/Sources/Main/EventPopupDialogView.swift index 04f4acb4..a18d2f47 100644 --- a/SodaLive/Sources/Main/EventPopupDialogView.swift +++ b/SodaLive/Sources/Main/EventPopupDialogView.swift @@ -31,7 +31,7 @@ struct EventPopupDialogView: View { .shared .setAppStep( step: .eventDetail( - event: eventPopup + eventId: eventPopup.id ) ) AppState.shared.eventPopup = nil diff --git a/SodaLive/Sources/Settings/Event/EventApi.swift b/SodaLive/Sources/Settings/Event/EventApi.swift index 436bbdff..c799d58f 100644 --- a/SodaLive/Sources/Settings/Event/EventApi.swift +++ b/SodaLive/Sources/Settings/Event/EventApi.swift @@ -11,6 +11,7 @@ import Moya enum EventApi { case getEvents case getEventPopup + case getEventDetail(id: Int) } extension EventApi: TargetType { @@ -25,24 +26,30 @@ extension EventApi: TargetType { case .getEventPopup: return "/event/popup" + + case .getEventDetail(let id): + return "/api/v2/events/\(id)" } } var method: Moya.Method { switch self { - case .getEvents, .getEventPopup: + case .getEvents, .getEventPopup, .getEventDetail: return .get } } var task: Task { switch self { - case .getEvents, .getEventPopup: + case .getEvents, .getEventPopup, .getEventDetail: return .requestPlain } } var headers: [String : String]? { + if case .getEventDetail = self, UserDefaults.string(forKey: .token).isEmpty { + return nil + } return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] } } diff --git a/SodaLive/Sources/Settings/Event/EventDetailView.swift b/SodaLive/Sources/Settings/Event/EventDetailView.swift index ebc184aa..77086f5d 100644 --- a/SodaLive/Sources/Settings/Event/EventDetailView.swift +++ b/SodaLive/Sources/Settings/Event/EventDetailView.swift @@ -9,38 +9,50 @@ import SwiftUI import Kingfisher struct EventDetailView: View { - - let event: EventItem + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: EventDetailViewModel + + init(eventId: Int) { + _viewModel = StateObject(wrappedValue: EventDetailViewModel(eventId: eventId)) + } var body: some View { - BaseView { + BaseView(isLoading: $viewModel.isLoading) { GeometryReader { proxy in VStack(spacing: 0) { - DetailNavigationBar(title: I18n.Settings.Event.detailTitle) + DetailNavigationBar(title: I18n.Settings.Event.detailTitle, backAction: { + viewModel.cancel() + dismiss() + }) ScrollView(.vertical, showsIndicators: false) { - KFImage(URL(string: event.detailImageUrl!)) - .cancelOnDisappear(true) - .resizable() - .scaledToFit() + if case .loaded(let event) = viewModel.state, + let detailImageUrl = event.detailImageUrl { + KFImage(URL(string: detailImageUrl)) + .cancelOnDisappear(true) + .resizable() + .scaledToFit() + } } Spacer() - if let link = event.link, link.count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - Text(I18n.Settings.Event.participateButton) - .appFont(size: 18.3, weight: .bold) - .foregroundColor(.white) - .padding(.vertical, 16) - .frame(width: screenSize().width - 26.7) - .background(Color(hex: "3e737c")) - .cornerRadius(10) - .padding(13.3) - .background(Color(hex: "222222")) - .cornerRadius(16.7, corners: [.topLeft, .topRight]) - .onTapGesture { - UIApplication.shared.open(url) - } + if let url = viewModel.participationURL, UIApplication.shared.canOpenURL(url) { + Button { + UIApplication.shared.open(url) + } label: { + Text(I18n.Settings.Event.participateButton) + .appFont(size: 18.3, weight: .bold) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "3e737c")) + .cornerRadius(10) + .padding(13.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + } + .buttonStyle(.plain) } if proxy.safeAreaInsets.bottom > 0 { @@ -52,19 +64,23 @@ struct EventDetailView: View { .edgesIgnoringSafeArea(.bottom) } } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .onAppear { + viewModel.load() + } + .onDisappear { + viewModel.cancel() + } + .onReceive(viewModel.$state) { state in + if state == .unavailable, viewModel.takeUnavailableDismissal() { + dismiss() + } + } } } struct EventDetailView_Previews: PreviewProvider { static var previews: some View { - EventDetailView( - event: EventItem( - id: 1, - thumbnailImageUrl: "", - detailImageUrl: "", - popupImageUrl: "", - link: "http://m.naver.com" - ) - ) + EventDetailView(eventId: 0) } } diff --git a/SodaLive/Sources/Settings/Event/EventListView.swift b/SodaLive/Sources/Settings/Event/EventListView.swift index 824d7883..a8389b17 100644 --- a/SodaLive/Sources/Settings/Event/EventListView.swift +++ b/SodaLive/Sources/Settings/Event/EventListView.swift @@ -38,7 +38,7 @@ struct EventListView: View { .contentShape(Rectangle()) .onTapGesture { if let _ = event.detailImageUrl { - AppState.shared.setAppStep(step: .eventDetail(event: event)) + AppState.shared.setAppStep(step: .eventDetail(eventId: event.id)) } else if let link = event.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } diff --git a/SodaLive/Sources/Settings/Event/EventRepository.swift b/SodaLive/Sources/Settings/Event/EventRepository.swift index 81370610..ae98477a 100644 --- a/SodaLive/Sources/Settings/Event/EventRepository.swift +++ b/SodaLive/Sources/Settings/Event/EventRepository.swift @@ -20,5 +20,8 @@ final class EventRepository { func getEventPopup() -> AnyPublisher { return api.requestPublisher(.getEventPopup) } -} + func getEventDetail(id: Int) -> AnyPublisher { + return api.requestPublisher(.getEventDetail(id: id)) + } +} diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 3d6c66e9..fa473cb2 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -637,7 +637,7 @@ struct MainView: View { private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) { if let eventItem = item.eventItem { performRecommendationDetailAction { - appState.setAppStep(step: .eventDetail(event: eventItem)) + appState.setAppStep(step: .eventDetail(eventId: eventItem.id)) } return } @@ -660,7 +660,7 @@ struct MainView: View { private func handleContentAudioBannerTap(_ item: AudioBannerResponse) { if let eventItem = item.eventItem { performRecommendationDetailAction { - appState.setAppStep(step: .eventDetail(event: eventItem)) + appState.setAppStep(step: .eventDetail(eventId: eventItem.id)) } return } diff --git a/SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift b/SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift new file mode 100644 index 00000000..9d4216cf --- /dev/null +++ b/SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift @@ -0,0 +1,114 @@ +import Foundation +import Combine + +final class EventDetailViewModel: ObservableObject { + enum State: Equatable { + case idle + case loading + case loaded(EventItem) + case unavailable + case error + } + + @Published private(set) var state: State = .idle + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + private let eventId: Int + private let repository = EventRepository() + private var subscription: AnyCancellable? + private var activeRequestID: UUID? + private var isUnavailableDismissalPending = false + + init(eventId: Int) { + self.eventId = eventId + } + + var participationURL: URL? { + guard case .loaded(let event) = state, + let link = event.link, + let url = URL(string: link), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = url.host, !host.isEmpty else { return nil } + return url + } + + func load() { + guard state == .idle else { return } + guard eventId > 0 else { + showUnavailable() + return + } + + isLoading = true + state = .loading + let requestID = UUID() + activeRequestID = requestID + + subscription = repository.getEventDetail(id: eventId) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.activeRequestID == requestID else { return } + self.activeRequestID = nil + self.showError() + } receiveValue: { [weak self] response in + guard let self, self.activeRequestID == requestID else { return } + self.activeRequestID = nil + self.isLoading = false + + guard response.statusCode == 200 else { + self.showError() + return + } + + do { + let decoder = JSONDecoder() + let envelope = try decoder.decode(ApiResponseWithoutData.self, from: response.data) + guard envelope.success else { + self.showUnavailable() + return + } + let decoded = try decoder.decode(ApiResponse.self, from: response.data) + guard let event = decoded.data, event.id > 0, event.id == self.eventId else { + self.showError() + return + } + self.state = .loaded(event) + } catch { + self.showError() + } + } + } + + func cancel() { + activeRequestID = nil + isUnavailableDismissalPending = false + subscription?.cancel() + subscription = nil + isLoading = false + if state == .loading { + state = .idle + } + } + + func takeUnavailableDismissal() -> Bool { + guard isUnavailableDismissalPending else { return false } + isUnavailableDismissalPending = false + return true + } + + private func showUnavailable() { + isLoading = false + isUnavailableDismissalPending = true + state = .unavailable + } + + private func showError() { + isLoading = false + errorMessage = I18n.Common.commonError + isShowPopup = true + state = .error + } +} diff --git a/work/tests/EventDetailCheck.swift b/work/tests/EventDetailCheck.swift new file mode 100644 index 00000000..2631c4f3 --- /dev/null +++ b/work/tests/EventDetailCheck.swift @@ -0,0 +1,313 @@ +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 diff --git a/work/tests/check-event-detail.sh b/work/tests/check-event-detail.sh new file mode 100644 index 00000000..a1a9be72 --- /dev/null +++ b/work/tests/check-event-detail.sh @@ -0,0 +1,30 @@ +#!/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" \ + -D EVENT_MOYA_DOUBLE -emit-module -emit-library -module-name Moya \ + "$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/libMoya.dylib" +swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \ + -I "$TEMP_DIR" -L "$TEMP_DIR" -lMoya \ + -D EVENT_COMBINE_MOYA_DOUBLE -emit-module -emit-library -module-name CombineMoya \ + "$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/libCombineMoya.dylib" +swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \ + -I "$TEMP_DIR" -L "$TEMP_DIR" -lMoya -lCombineMoya \ + -Xlinker -rpath -Xlinker "$TEMP_DIR" \ + "$ROOT/SodaLive/Sources/Settings/Event/EventApi.swift" \ + "$ROOT/SodaLive/Sources/Settings/Event/EventRepository.swift" \ + "$ROOT/SodaLive/Sources/Settings/Event/Event.swift" \ + "$ROOT/SodaLive/Sources/V2/Settings/Event/EventDetailViewModel.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponseWithoutData.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + "$ROOT/work/tests/EventDetailCheck.swift" \ + -o "$TEMP_DIR/check-event-detail" +"$TEMP_DIR/check-event-detail" +swiftc -swift-version 5 -parse-as-library -module-cache-path "$TEMP_DIR/module-cache" \ + -D EVENT_ROUTE_CHECK "$ROOT/work/tests/EventDetailCheck.swift" -o "$TEMP_DIR/check-event-routes" +"$TEMP_DIR/check-event-routes" "$ROOT"