import Foundation import Combine #if SB_MOYA_DOUBLE public enum Method { case get, post } public enum Task { case requestPlain, requestJSONEncodable(any Encodable) } 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 SubBannerTransport { public static var request: ((any TargetType) -> AnyPublisher)! } public final class MoyaProvider { public init() {} } #elseif SB_COMBINE_MOYA_DOUBLE import Moya extension MoyaProvider { public func requestPublisher(_ target: T) -> AnyPublisher { SubBannerTransport.request(target) } } #elseif SB_UI_CHECK enum LanguageOption: String { case system, ko, en, ja } #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 = "common-error" } enum MainContentRecommendation { static let emptyStateMessage = "empty" static let loadFailedMessage = "load-failed" } enum HomeRecommendation { static let activityLive = "live" static let activityAudio = "audio" static let activityCommunity = "community" } } enum DateParser { static func relativeTimeText(fromUTC: String, fallback: String, now: Date) -> String { fallback } } func DEBUG_LOG(_ message: String) {} func ERROR_LOG(_ message: String) {} struct GetCommunityPostListResponse: Decodable {} final class CreatorCommunityRepository { func communityPostLike(postId: Int) -> AnyPublisher { fatalError("Unrelated community request must not execute") } func purchaseCommunityPost(postId: Int) -> AnyPublisher { fatalError("Unrelated purchase request must not execute") } } @main struct SubBannerCheck { static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { guard condition() else { fputs("FAIL: \(message)\n", stderr); exit(1) } } static func payload(home: Bool, items: String = "[]", success: Bool = true) -> String { let fields = home ? #""lives":[],"banners":[{"imageUrl":"https://synthetic.invalid/main","link":"https://synthetic.invalid/legacy"}],"recentlyActiveCreators":[],"recentDebutCreators":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[]"# : #""banners":[],"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[],"pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[]"# return #"{"success":\#(success),"message":"server-message","errorProperty":null,"data":{\#(fields),"subBanners":\#(items)}}"# } static func checkAPI() { for token in ["", " \t\n", "synthetic-token", " original-token "] { UserDefaults.set(token, forKey: .token) var paths: [String] = [] SubBannerTransport.request = { target in paths.append(target.path) expect(target.baseURL.absoluteString == BASE_URL, "Base URL changed") let follow = target.path.hasSuffix("/creators/follow") let expectedHeader = follow || !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Bearer \(token)" : nil expect(target.headers?["Authorization"] == expectedHeader, "Anonymous GET must omit Authorization; authenticated/follow must preserve raw token: \(target.path)") if follow { expect(target.method == .post, "Follow method changed") guard case .requestJSONEncodable(let body) = target.task else { fatalError("Follow body missing") } let encoded = try! JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as! [String: [Int]] expect(encoded == ["creatorIds": [7, 9]], "Follow payload changed") } else { expect(target.method == .get, "Recommendations must GET") guard case .requestPlain = target.task else { fatalError("No extra query/body allowed") } } return Empty().eraseToAnyPublisher() } let home = MainHomeRecommendationRepository() _ = home.getRecommendations() _ = MainContentRecommendationRepository().getAudioRecommendations() _ = home.followRecommendedCreators(request: FollowRecommendedCreatorsRequest(creatorIds: [7, 9])) expect(paths == ["/api/v2/home/recommendations", "/api/v2/audio/recommendations", "/api/v2/home/recommendations/creators/follow"], "Extra request or changed path") } print("PASS: actual APIs/repositories, two exact GETs, requestPlain, anonymous/whitespace auth, original bearer, adjacent follow POST/body/header") } static func checkVMs() { for home in [true, false] { let failures: [(String, Int, Bool, String)] = [ (payload(home: home), 201, false, "http"), (payload(home: home), 401, false, "http"), (payload(home: home), 503, false, "http"), ("bad", 503, false, "http"), (payload(home: home, success: false), 200, false, "server"), (#"{"success":false,"data":null,"message":"server-message"}"#, 200, false, "server"), ("broken", 200, false, "decode"), (payload(home: home), 200, true, "transport") ] for (body, status, transportFailure, kind) in failures { let subject = PassthroughSubject() var requests = 0 SubBannerTransport.request = { target in requests += 1 expect(target.path == (home ? "/api/v2/home/recommendations" : "/api/v2/audio/recommendations"), "VM uses wrong repository") return subject.eraseToAnyPublisher() } let homeModel = MainHomeRecommendationViewModel() let contentModel = MainContentRecommendationViewModel() if home { homeModel.fetchRecommendations() } else { contentModel.fetchRecommendations() } if transportFailure { subject.send(completion: .failure(.simulatedTransportFailure)) } else { subject.send(Response(statusCode: status, data: Data(body.utf8))) subject.send(completion: .finished) } expect(requests == 1, "VM must make exactly one request") expect(home ? homeModel.recommendations == nil : contentModel.recommendations == nil, "HTTP \(status)/\(kind) must not become successful recommendations (home=\(home))") expect(home ? homeModel.isShowPopup : contentModel.isShowPopup, "Existing failure popup lost") expect(home ? !homeModel.isLoading : !contentModel.isLoading && contentModel.hasLoaded, "Failure must finish loading") let message = home ? homeModel.errorMessage : contentModel.errorMessage expect(message == (kind == "server" ? "server-message" : (home ? "common-error" : "load-failed")), "Failure message semantics changed") } } print("PASS: actual two VMs reject non200 valid/invalid JSON, success false, decode/transport errors; preserve error UI and loading") } static func item(id: Int = 1, placement: String = "HOME_RECOMMENDATION", image: String = "https://synthetic.invalid/image", web: String? = "https://synthetic.invalid/web", ios: String? = nil, android: String? = nil) -> String { func encoded(_ value: String?) -> String { String(data: try! JSONEncoder().encode(value), encoding: .utf8)! } return #"{"id":\#(id),"placement":"\#(placement)","imageUrl":\#(encoded(image)),"webUrl":\#(encoded(web)),"iosDeepLink":\#(encoded(ios)),"androidDeepLink":\#(encoded(android))}"# } static func decode(_ json: String) -> SubBanner { try! JSONDecoder().decode(SubBanner.self, from: Data(json.utf8)) } static func checkDTOs() { for id in [9_007_199_254_740_993, Int.max] { let banner = decode(item(id: id)) expect(banner.id == id && banner.placement == .homeRecommendation, "Lossless integer/enum decoding") } for bad in [item().replacingOccurrences(of: "HOME_RECOMMENDATION", with: "OTHER"), item().replacingOccurrences(of: "\"id\":1", with: "\"id\":1.5")] { expect((try? JSONDecoder().decode(SubBanner.self, from: Data(bad.utf8))) == nil, "Invalid enum/fractional ID accepted") } let list = (1...23).reversed().map { item(id: $0) } let mixed = "[" + (list + [item(id: 24, placement: "CONTENT_RECOMMENDATION"), item(id: 25, web: nil, android: "android://only"), item(id: 26, image: "/relative")]).joined(separator: ",") + "]" let home = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: true, items: mixed).utf8)) let content = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: false, items: mixed).utf8)) expect(home.data!.eligibleSubBanners.map(\.id) == Array((1...23).reversed()), "Full 23+ order, eligibility or home placement lost") expect(home.data!.banners.first?.link == "https://synthetic.invalid/legacy" && home.data!.subBanners.count == 26, "Legacy/data arrays lost") expect(content.data!.eligibleSubBanners.map(\.id) == [24] && !content.data!.isEmpty, "Content subbanner-only must not be empty") for items in ["[]", "[" + item() + "]", "[" + item(placement: "CONTENT_RECOMMENDATION", web: nil, android: "android://only") + "]"] { let value = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: false, items: items).utf8)) expect(value.data!.isEmpty, "Filtered-empty content must remain empty") } for home in [true, false] { let missing = payload(home: home).replacingOccurrences(of: ",\"subBanners\":[]", with: "") let invalid = payload(home: home, items: "null") for json in [missing, invalid] { if home { expect((try? JSONDecoder().decode(ApiResponse.self, from: Data(json.utf8))) == nil, "Home subBanners required") } else { expect((try? JSONDecoder().decode(ApiResponse.self, from: Data(json.utf8))) == nil, "Content subBanners required") } } let subject = PassthroughSubject() SubBannerTransport.request = { _ in subject.eraseToAnyPublisher() } let homeModel = MainHomeRecommendationViewModel() let contentModel = MainContentRecommendationViewModel() if home { homeModel.fetchRecommendations() } else { contentModel.fetchRecommendations() } subject.send(Response(statusCode: 200, data: Data(payload(home: home, items: mixed).utf8))) expect(home ? homeModel.recommendations?.eligibleSubBanners.count == 23 : contentModel.recommendations?.eligibleSubBanners.count == 1 && !contentModel.shouldShowEmptyState, "Actual VM success/filter delivery") subject.send(Response(statusCode: 503, data: Data(payload(home: home).utf8))) expect(home ? homeModel.recommendations?.subBanners.count == 26 : contentModel.recommendations?.subBanners.count == 26, "Failure must not replace existing data with empty") } print("PASS: actual DTOs/VM success, >2^53 and Int.max, enum, required array, 23+ full order, placement/eligibility, legacy banner, subbanner-only/filtered-empty, failure retains data") } static func checkURLsAndOpener() { let escaped = "HTTPS://synthetic.invalid/a%2fb?q=a%20b&next=soda%3A%2F%2Fgo#Raw" for raw in ["https://synthetic.invalid", "http://synthetic.invalid/x", escaped] { let banner = decode(item(image: raw, web: raw)) expect(banner.imageURL?.absoluteString == raw && banner.webURL?.absoluteString == raw, "Accepted HTTP URI must be exact") } let badWeb = ["", " ", "\t\n", "/relative", "//synthetic.invalid", "https://", "https:///", "https:/path", "soda://host", "https://synthetic.invalid/a b", "https://synthetic.invalid/%zz", " https://synthetic.invalid", "https://synthetic.invalid\n"] for raw in badWeb { expect(decode(item(web: raw)).webURL == nil && decode(item(image: raw)).imageURL == nil, "Invalid web/image accepted: \(raw)") } let badDeep = ["", " ", "/relative", "//host", "1soda:abc", "JavaScript:alert(1)", "DATA:text/plain,x", "FILE:///tmp/x", "Content://host", "soda://a b", "soda://host/%zz", " soda://host", "soda://host\n"] for raw in badDeep { expect(decode(item(ios: raw)).iosURL == nil, "Invalid deep URI accepted: \(raw)") } for raw in ["soda://host/a%2fb?q=x%20y", "SoDa:opaque", escaped] { expect(decode(item(ios: raw)).iosURL?.absoluteString == raw, "Deep URI normalized") } for mask in 0..<8 { let web = mask & 1 != 0 ? escaped : nil let ios = mask & 2 != 0 ? "SoDa://open/a%2fb?q=x%20y" : nil let android = mask & 4 != 0 ? "android://never" : nil let banner = decode(item(web: web, ios: ios, android: android)) expect(banner.isEligible == (mask & 3 != 0), "All 8 link combinations eligibility") for succeeds in [false, true] { var attempts: [String] = [] var pending: ((Bool) -> Void)? SubBannerLinkOpener.open(banner) { url, completion in attempts.append(url.absoluteString) pending = completion } expect(attempts == (ios.map { [$0] } ?? web.map { [$0] } ?? []), "iOS-first initial attempt/order/raw URI") pending?(succeeds) let expected = ios.map { [$0] + (!succeeds ? web.map { [$0] } ?? [] : []) } ?? web.map { [$0] } ?? [] expect(attempts == expected, "iOS result must fallback web once; never Android (mask=\(mask))") if ios != nil && !succeeds && web != nil { pending?(false) } expect(attempts == expected, "Both failures must not retry") } } print("PASS: HTTP(S)+host, absolute URI/blacklist, malformed/blank/auto-encoded rejection, exact escaped URI, all 8 link combinations, completion-based iOS/web attempts, Android never") } static func main() { if CommandLine.arguments.contains("--http") { checkVMs(); return } checkAPI() checkVMs() checkDTOs() checkURLsAndOpener() print("PASS: sub-banner production-code checks; native Combine, Moya transport double; no live requests") } } #endif