261 lines
16 KiB
Swift
261 lines
16 KiB
Swift
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<Response, MoyaError>)!
|
|
}
|
|
public final class MoyaProvider<T: TargetType> { public init() {} }
|
|
#elseif SB_COMBINE_MOYA_DOUBLE
|
|
import Moya
|
|
extension MoyaProvider {
|
|
public func requestPublisher(_ target: T) -> AnyPublisher<Response, MoyaError> {
|
|
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<Response, MoyaError> {
|
|
fatalError("Unrelated community request must not execute")
|
|
}
|
|
func purchaseCommunityPost(postId: Int) -> AnyPublisher<Response, MoyaError> {
|
|
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<Response, MoyaError>().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"),
|
|
("<html>bad</html>", 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<Response, MoyaError>()
|
|
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<HomeRecommendationResponse>.self, from: Data(payload(home: true, items: mixed).utf8))
|
|
let content = try! JSONDecoder().decode(ApiResponse<AudioRecommendationsResponse>.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<AudioRecommendationsResponse>.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<HomeRecommendationResponse>.self, from: Data(json.utf8))) == nil, "Home subBanners required")
|
|
} else {
|
|
expect((try? JSONDecoder().decode(ApiResponse<AudioRecommendationsResponse>.self, from: Data(json.utf8))) == nil, "Content subBanners required")
|
|
}
|
|
}
|
|
let subject = PassthroughSubject<Response, MoyaError>()
|
|
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
|