From e2511855fe2d46871419ab13063400a38425c997 Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 26 Jun 2026 20:08:41 +0800 Subject: [PATCH] feat(membership): add StoreKit subscription and membership center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreKitManager: purchase, Transaction.updates, restore, finish - MembershipView: plan cards, purchase section, usage grid, compliance - MembershipService: API client for /membership/* endpoints - MembershipModels: Codable DTOs for plans/grants/transactions - Route.membership + ProfileView entry point - Products.storekit: Sandbox test products (monthly/yearly) - Remove hardcoded price fallback (displayPrice nil → 价格暂不可用) - Add MembershipModelsTests (12 tests) Co-Authored-By: Claude --- .../Core/Models/MembershipModels.swift | 68 ++++ .../AIStudyApp/Core/Navigation/Route.swift | 5 + .../AIStudyApp/Core/Services/APIService.swift | 28 ++ .../Core/Services/StoreKitManager.swift | 152 +++++++++ .../Features/Membership/MembershipView.swift | 323 ++++++++++++++++++ .../Features/Profile/ProfileView.swift | 5 + AIStudyApp/AIStudyApp/Products.storekit | 53 +++ .../MembershipModelsTests.swift | 61 ++++ 8 files changed, 695 insertions(+) create mode 100644 AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift create mode 100644 AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift create mode 100644 AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift create mode 100644 AIStudyApp/AIStudyApp/Products.storekit create mode 100644 AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift diff --git a/AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift b/AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift new file mode 100644 index 0000000..6073b17 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Models/MembershipModels.swift @@ -0,0 +1,68 @@ +// +// MembershipModels.swift — M-MEMBER-01-09 +// + +import Foundation + +// MARK: - API Responses + +struct MembershipPlanResponse: Codable { + let plans: [PlanItem] +} + +struct PlanItem: Codable, Identifiable { + var id: String { planCode } + let planCode: String + let name: String + let entitlements: Entitlements? + let priceMonthly: Int + let priceYearly: Int +} + +struct Entitlements: Codable { + let maxKnowledgeBases: Int + let maxStorageBytes: Int + let maxFileSizeBytes: Int + let monthlyOcrPages: Int + let monthlyVisionPages: Int + let monthlyChatCount: Int + let monthlyAiAnalysisCount: Int + let monthlyRecallCount: Int + let monthlyCardGenCount: Int + let monthlyQuizCount: Int +} + +struct MyMembershipResponse: Codable { + let effectivePlan: String + let grants: [GrantSummary] + let appAccountToken: String? + let entitlements: Entitlements? + let usageSummary: UsageSummary? +} + +struct GrantSummary: Codable, Identifiable { + var id: String { "\(source)-\(planCode)" } + let source: String + let status: String + let planCode: String + let planName: String + let startedAt: String + let expiresAt: String? + let autoRenewStatus: String? + let environment: String? +} + +struct UsageSummary: Codable { + let used: Int? + let remaining: Int? + let nextRefresh: String? +} + +struct TransactionSubmitBody: Codable { + let signedTransactionInfo: String +} + +struct TransactionSubmitResponse: Codable { + let transactionId: String + let status: String +} diff --git a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift index 928302d..1cdecd0 100644 --- a/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift +++ b/AIStudyApp/AIStudyApp/Core/Navigation/Route.swift @@ -35,6 +35,9 @@ enum Route: Hashable { case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "") case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?) + // Membership + case membership + // Profile case notificationList case settings @@ -68,6 +71,7 @@ extension Route { case .goalSetting: "GoalSettingDetailView" case .methodPreference: "MethodPreferenceView" case .feedbackForm: "FeedbackFormView" + case .membership: "MembershipView" case .editProfile: "EditProfilePage" case .importReview: "ImportReviewPage" case .quizList: "QuizListView" @@ -102,6 +106,7 @@ extension Route { AnyView(LearningSessionView(taskTitle: title, taskType: type, taskColor: Color(hex: colorHex))) case .studyHome: AnyView(StudyHomeView(selectedTab: .constant("study"))) + case .membership: AnyView(MembershipView()) case .notificationList: AnyView(NotificationListView()) case .settings: AnyView(SettingsView()) case .goalSetting: AnyView(GoalSettingDetailView()) diff --git a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift index 73cee9c..8de8261 100644 --- a/AIStudyApp/AIStudyApp/Core/Services/APIService.swift +++ b/AIStudyApp/AIStudyApp/Core/Services/APIService.swift @@ -632,6 +632,34 @@ class NotificationService { } } +// MARK: - Membership (M-MEMBER-01-09) + +@MainActor +class MembershipService { + static let shared = MembershipService() + private let client = APIClient.shared + + func getPlans() async throws -> MembershipPlanResponse { + try await client.request("/membership/plans") + } + + func myMembership() async throws -> MyMembershipResponse { + try await client.request("/membership/me") + } + + func getUsage() async throws -> UsageSummary { + try await client.request("/membership/usage") + } + + func submitTransaction(signedTransactionInfo: String) async throws -> TransactionSubmitResponse { + try await client.request( + "/membership/apple/transactions", + method: "POST", + body: ["signedTransactionInfo": signedTransactionInfo] as [String: String] + ) + } +} + struct NotificationPreferences: Codable { var reviewReminder: Bool? var newFeatures: Bool? diff --git a/AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift b/AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift new file mode 100644 index 0000000..bf07725 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Core/Services/StoreKitManager.swift @@ -0,0 +1,152 @@ +import StoreKit +import Combine +import SwiftUI + +/// M-MEMBER-01-09: StoreKit 2 核心管理器 +@MainActor +final class StoreKitManager: ObservableObject { + static let shared = StoreKitManager() + + @Published var products: [StoreKit.Product] = [] + @Published var isLoadingProducts = false + @Published var purchaseState: PurchaseState = .idle + @Published var currentEntitlementActive = false + + enum PurchaseState: Equatable { + case idle + case loading + case success(transactionId: String) + case userCancelled + case pending + case failed(String) + } + + private let productIds = [ + "cloud.longde.aistudyapp.premium.monthly", + "cloud.longde.aistudyapp.premium.yearly", + ] + + private var updatesTask: Task? + + private init() {} + + func loadProducts() async { + isLoadingProducts = true + defer { isLoadingProducts = false } + do { + products = try await StoreKit.Product.products(for: productIds) + .sorted { $0.price < $1.price } + } catch { + products = [] + } + } + + // MARK: - Purchase + + func purchase(_ product: StoreKit.Product, appAccountToken: UUID) async { + purchaseState = .loading + + let result: Product.PurchaseResult + do { + result = try await product.purchase(options: [ + .appAccountToken(appAccountToken), + ]) + } catch { + purchaseState = .failed(error.localizedDescription) + return + } + + switch result { + case .success(let verification): + await handleVerifiedTransaction(verification) + + case .userCancelled: + purchaseState = .userCancelled + + case .pending: + purchaseState = .pending + + @unknown default: + purchaseState = .failed("Unknown purchase result") + } + } + + // MARK: - Transaction Handling + + private func handleVerifiedTransaction(_ verification: StoreKit.VerificationResult) async { + // B1: 从 VerificationResult 提取 JWS(非 Transaction.jsonRepresentation) + let jws = verification.jwsRepresentation + + switch verification { + case .verified(let transaction): + let success = await submitToBackend(jws: jws) + if success { + currentEntitlementActive = true + purchaseState = .success(transactionId: String(transaction.id)) + } else { + purchaseState = .failed("Backend verification failed") + } + await transaction.finish() + + case .unverified: + purchaseState = .failed("Transaction unverified") + } + } + + private func submitToBackend(jws: String) async -> Bool { + do { + let _: TransactionSubmitResponse = try await APIClient.shared.request( + "/membership/apple/transactions", + method: "POST", + body: ["signedTransactionInfo": jws] + ) + return true + } catch { + return false + } + } + + // MARK: - Transaction Listener + + func startListening() { + updatesTask = Task.detached { + for await verification in Transaction.updates { + await self.handleVerifiedTransaction(verification) + } + } + } + + func stopListening() { + updatesTask?.cancel() + updatesTask = nil + } + + // MARK: - Restore Purchases + + func restorePurchases() async -> Bool { + var restored = false + do { + try await AppStore.sync() + for await verification in Transaction.currentEntitlements { + // B1: 从 VerificationResult 提取 JWS + let success = await submitToBackend(jws: verification.jwsRepresentation) + if case .verified(let tx) = verification { + if success { restored = true; currentEntitlementActive = true } + await tx.finish() + } + } + } catch { + return false + } + return restored + } + + // MARK: - Cleanup + + func handleLogout() { + stopListening() + currentEntitlementActive = false + purchaseState = .idle + } +} + diff --git a/AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift b/AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift new file mode 100644 index 0000000..69973d8 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Features/Membership/MembershipView.swift @@ -0,0 +1,323 @@ +import SwiftUI +import Combine +import StoreKit + +// MARK: - MembershipView (M-MEMBER-01-10) + +struct MembershipView: View { + @StateObject private var store = MembershipStore() + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Color.zxBg0.ignoresSafeArea() + + switch store.state { + case .loading: + VStack(spacing: 12) { + ProgressView().tint(Color.zxPurple) + Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) + } + + case .loaded(let membership, let plans): + loadedView(membership: membership, plans: plans) + + case .error(let msg): + VStack(spacing: 16) { + Image(systemName: "wifi.slash").font(.system(size: 36)).foregroundColor(Color.zxF03) + Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04) + Button("重试") { Task { await store.load() } } + .font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPurple) + } + } + } + .navigationTitle("会员中心").navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .tabBar) + .task { await store.load() } + } + + // MARK: - Loaded State + + @ViewBuilder + private func loadedView(membership: MyMembershipResponse, plans: MembershipPlanResponse) -> some View { + ScrollView { + VStack(spacing: 16) { + // Current plan card + currentPlanCard(membership) + + // Purchase section (only if not premium) + if membership.effectivePlan != "premium" { + purchaseSection(plans) + } + + // Usage + if let entitlements = membership.entitlements { + usageSection(entitlements: entitlements) + } + + // Actions + actionsSection(membership) + + // Compliance + complianceSection + } + .padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100) + } + .scrollIndicators(.hidden) + .alert(store.alertTitle, isPresented: $store.showAlert) { + Button("确定", role: .cancel) {} + } message: { + Text(store.alertMessage) + } + } + + // MARK: - Current Plan + + private func currentPlanCard(_ m: MyMembershipResponse) -> some View { + VStack(spacing: 12) { + HStack { + Image(systemName: m.effectivePlan == "premium" ? "crown.fill" : "person.fill") + .font(.system(size: 24)).foregroundColor(m.effectivePlan == "premium" ? Color.zxAmber : Color.zxF03) + Text(m.effectivePlan == "premium" ? "Premium 会员" : "免费用户") + .font(.system(size: 18, weight: .bold)).foregroundColor(Color.zxF0) + Spacer() + } + + if let expires = m.grants.first?.expiresAt, m.effectivePlan == "premium" { + HStack { + Text("到期时间:\(formatDate(expires))").font(.system(size: 12)).foregroundColor(Color.zxF04) + Spacer() + if m.grants.first?.autoRenewStatus == "off" { + Text("续订已关闭").font(.system(size: 11)).foregroundColor(Color.zxCoral) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.zxCoral.opacity(0.1)).clipShape(Capsule()) + } + } + } + + if let grant = m.grants.first { + HStack(spacing: 12) { + Label("来源:\(sourceLabel(grant.source))", systemImage: "checkmark.shield") + .font(.system(size: 11)).foregroundColor(Color.zxF04) + if let env = grant.environment, env == "Sandbox" { + Text("沙盒").font(.system(size: 10)).foregroundColor(Color.zxAmber) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.zxAmber.opacity(0.15)).clipShape(Capsule()) + } + } + } + } + .padding(20).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - Purchase + + @ViewBuilder + private func purchaseSection(_ plans: MembershipPlanResponse) -> some View { + VStack(spacing: 8) { + Text("升级 Premium").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(plans.plans) { plan in + Button { + Task { await store.purchase(plan: plan) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(plan.name).font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0) + Text(plan.planCode.contains("yearly") ? "每年自动续订,更优惠" : "每月自动续订") + .font(.system(size: 11)).foregroundColor(Color.zxF04) + } + Spacer() + if store.purchasingPlanId == plan.planCode { + ProgressView().tint(Color.zxPurple) + } else if let price = store.displayPrice(for: plan.planCode) { + Text(price) + .font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxPurple) + } else { + Text("价格暂不可用") + .font(.system(size: 12)).foregroundColor(Color.zxF04) + } + } + .padding(16).background(Color.zxFill004) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxPurple.opacity(0.15), lineWidth: 1)) + } + .disabled(store.purchasingPlanId != nil) + } + } + } + + // MARK: - Usage + + private func usageSection(entitlements: Entitlements) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("额度").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + QuotaItem(label: "知识库", value: "\(entitlements.maxKnowledgeBases)") + QuotaItem(label: "AI 对话/月", value: entitlements.monthlyChatCount > 0 ? "\(entitlements.monthlyChatCount)" : "无限") + QuotaItem(label: "OCR/月", value: "\(entitlements.monthlyOcrPages) 页") + QuotaItem(label: "测验/天", value: "\(entitlements.monthlyQuizCount)") + QuotaItem(label: "存储", value: formatBytes(entitlements.maxStorageBytes)) + } + } + } + + // MARK: - Actions + + private func actionsSection(_ m: MyMembershipResponse) -> some View { + VStack(spacing: 8) { + Button { Task { await store.restore() } } label: { + HStack { + Text("恢复购买").font(.system(size: 14)).foregroundColor(Color.zxPurple) + Spacer() + if store.isRestoring { ProgressView().tint(Color.zxPurple) } + }.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + } + + if m.effectivePlan == "premium" { + Button { store.openManageSubscription() } label: { + HStack { + Text("管理订阅").font(.system(size: 14)).foregroundColor(Color.zxF04) + Spacer() + Image(systemName: "arrow.up.forward").font(.system(size: 12)).foregroundColor(Color.zxF03) + }.padding(16).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + } + } + + // MARK: - Compliance + + private var complianceSection: some View { + VStack(spacing: 8) { + Link("隐私政策", destination: URL(string: "https://longde.cloud/privacy")!) + .font(.system(size: 13)).foregroundColor(Color.zxF04) + Link("用户协议", destination: URL(string: "https://longde.cloud/terms")!) + .font(.system(size: 13)).foregroundColor(Color.zxF04) + Text("自动续订说明:订阅将在到期前 24 小时内自动续订,可随时在系统设置中取消。") + .font(.system(size: 11)).foregroundColor(Color.zxF04).multilineTextAlignment(.center) + Link("联系支持", destination: URL(string: "mailto:support@longde.cloud")!) + .font(.system(size: 13)).foregroundColor(Color.zxPurple) + } + .padding(.top, 8) + } + + // MARK: - Helpers + + private func formatDate(_ iso: String) -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withFullDate] + return f.date(from: iso)?.formatted(date: .abbreviated, time: .omitted) ?? iso.prefix(10).description + } + + private func formatBytes(_ b: Int) -> String { + if b >= 1_073_741_824 { return String(format: "%.0f GB", Double(b) / 1_073_741_824) } + if b >= 1_048_576 { return String(format: "%.0f MB", Double(b) / 1_048_576) } + return "\(b) B" + } + + private func sourceLabel(_ s: String) -> String { + switch s { case "apple": return "Apple"; case "admin": return "管理员"; case "trial": return "试用"; default: return s } + } +} + +// MARK: - Quota Item + +struct QuotaItem: View { + let label: String + let value: String + var body: some View { + VStack(spacing: 4) { + Text(value).font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0) + Text(label).font(.system(size: 11)).foregroundColor(Color.zxF04) + } + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - MembershipStore + +@MainActor +final class MembershipStore: ObservableObject { + @Published var state: State = .loading + @Published var purchasingPlanId: String? = nil + @Published var isRestoring = false + @Published var showAlert = false + var alertTitle = ""; var alertMessage = "" + + enum State { + case loading + case loaded(membership: MyMembershipResponse, plans: MembershipPlanResponse) + case error(String) + } + + func load() async { + state = .loading + do { + async let membership = MembershipService.shared.myMembership() + async let plans = MembershipService.shared.getPlans() + state = try await .loaded(membership: membership, plans: plans) + } catch { + state = .error("加载失败:\(error.localizedDescription)") + } + } + + func purchase(plan: PlanItem) async { + purchasingPlanId = plan.planCode + guard let tokenStr = try? await MembershipService.shared.myMembership().appAccountToken, + let uuid = UUID(uuidString: tokenStr) else { + show("无法获取账号令牌") + purchasingPlanId = nil; return + } + + let pm = StoreKitManager.shared + await pm.loadProducts() + guard let product = pm.products.first(where: { + $0.id == (plan.planCode.contains("yearly") + ? "cloud.longde.aistudyapp.premium.yearly" + : "cloud.longde.aistudyapp.premium.monthly") + }) else { + show("商品不可用"); purchasingPlanId = nil; return + } + + await pm.purchase(product, appAccountToken: uuid) + + switch pm.purchaseState { + case .success: await load() + case .userCancelled: break + case .pending: show("购买处理中,请稍候") + case .failed(let msg): show("购买失败:\(msg)") + default: break + } + purchasingPlanId = nil + } + + func restore() async { + isRestoring = true + let restored = await StoreKitManager.shared.restorePurchases() + if restored { await load() } else { show("未找到可恢复的订阅") } + isRestoring = false + } + + /// 从 StoreKit Product 获取本地化价格。未加载时返回 nil。 + func displayPrice(for planCode: String) -> String? { + let productId = planCode.contains("yearly") + ? "cloud.longde.aistudyapp.premium.yearly" + : "cloud.longde.aistudyapp.premium.monthly" + return StoreKitManager.shared.products.first(where: { $0.id == productId })?.displayPrice + } + + /// N2: 使用系统 API 打开订阅管理 + func openManageSubscription() { + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + Task { try? await AppStore.showManageSubscriptions(in: scene) } + } + } + + private func show(_ msg: String) { + alertTitle = "提示"; alertMessage = msg; showAlert = true + } +} diff --git a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift index 7562c1b..764f244 100644 --- a/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift +++ b/AIStudyApp/AIStudyApp/Features/Profile/ProfileView.swift @@ -29,6 +29,11 @@ struct ProfileView: View { .accessibilityLabel("设置") }.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 4) profileCard + // M-MEMBER-01-10: 会员中心入口 + NavigationLink(value: Route.membership) { + ZXProfileMenuRow(icon: "crown", title: "会员中心", desc: "查看和管理你的会员订阅") + }.foregroundColor(.primary) + ZXProfileDivider() VStack(spacing: 0) { NavigationLink(value: Route.goalSetting) { ZXProfileMenuRow(icon: "target", title: "学习目标设置", desc: "调整你的学习目标") diff --git a/AIStudyApp/AIStudyApp/Products.storekit b/AIStudyApp/AIStudyApp/Products.storekit new file mode 100644 index 0000000..ab5b186 --- /dev/null +++ b/AIStudyApp/AIStudyApp/Products.storekit @@ -0,0 +1,53 @@ +{ + "identifier" : "F6394B01", + "nonRenewingSubscriptions" : [], + "products" : [ + { + "displayName" : "Premium Monthly", + "familyDisplayName" : "Premium", + "productType" : "AutoRenewableSubscription", + "referenceName" : "Premium Monthly", + "subscriptionGroupID" : "premium", + "subscriptionGroupDisplayName" : "Premium", + "productID" : "cloud.longde.aistudyapp.premium.monthly", + "price" : 28, + "subscriptionPeriod" : "P1M", + "localizations" : [ + { + "displayName" : "Premium 月付", + "description" : "每月自动续订", + "locale" : "zh_CN" + } + ] + }, + { + "displayName" : "Premium Yearly", + "familyDisplayName" : "Premium", + "productType" : "AutoRenewableSubscription", + "referenceName" : "Premium Yearly", + "subscriptionGroupID" : "premium", + "subscriptionGroupDisplayName" : "Premium", + "productID" : "cloud.longde.aistudyapp.premium.yearly", + "price" : 198, + "subscriptionPeriod" : "P1Y", + "localizations" : [ + { + "displayName" : "Premium 年付", + "description" : "每年自动续订,省 41%", + "locale" : "zh_CN" + } + ] + } + ], + "settings" : { + "failTransactionsEnabled" : false, + "locale" : "zh_CN", + "name" : "本地测试", + "storefront" : "CHN", + "timeRate" : 1 + }, + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift b/AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift new file mode 100644 index 0000000..f8e2cf4 --- /dev/null +++ b/AIStudyApp/AIStudyAppTests/MembershipModelsTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import AIStudyApp + +final class MembershipModelsTests: XCTestCase { + + func testPlanItemDecoding() throws { + let json = #"{"planCode":"premium","name":"Premium","entitlements":{"maxKnowledgeBases":10,"maxStorageBytes":10737418240,"maxFileSizeBytes":52428800,"monthlyOcrPages":50,"monthlyVisionPages":30,"monthlyChatCount":300,"monthlyAiAnalysisCount":0,"monthlyRecallCount":0,"monthlyCardGenCount":0,"monthlyQuizCount":0},"priceMonthly":2800,"priceYearly":19800}"# + let data = json.data(using: .utf8)! + let plan = try JSONDecoder().decode(PlanItem.self, from: data) + XCTAssertEqual(plan.planCode, "premium") + XCTAssertEqual(plan.entitlements?.maxKnowledgeBases, 10) + } + + func testMyMembershipDecoding() throws { + let json = #"{"effectivePlan":"premium","grants":[{"source":"apple","status":"active","planCode":"premium","planName":"Premium","startedAt":"2026-01-01T00:00:00Z","expiresAt":null,"autoRenewStatus":"on","environment":"Production"}],"appAccountToken":"uuid","entitlements":null,"usageSummary":null}"# + let data = json.data(using: .utf8)! + let m = try JSONDecoder().decode(MyMembershipResponse.self, from: data) + XCTAssertEqual(m.effectivePlan, "premium") + XCTAssertEqual(m.grants.first?.source, "apple") + } + + func testTransactionSubmitBodyEncoding() throws { + let body = TransactionSubmitBody(signedTransactionInfo: "test-jws") + let data = try JSONEncoder().encode(body) + let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] + XCTAssertEqual(dict?["signedTransactionInfo"], "test-jws") + } + + func testMembershipPlanResponseDecoding() throws { + let json = #"{"plans":[]}"# + let data = json.data(using: .utf8)! + let r = try JSONDecoder().decode(MembershipPlanResponse.self, from: data) + XCTAssertTrue(r.plans.isEmpty) + } + + func testTransactionSubmitResponseDecoding() throws { + let json = #"{"transactionId":"tx-1","status":"created"}"# + let data = json.data(using: .utf8)! + let r = try JSONDecoder().decode(TransactionSubmitResponse.self, from: data) + XCTAssertEqual(r.transactionId, "tx-1") + } + + func testQuizQuestionTypeChoice() { XCTAssertEqual(QuizQuestionType.from(rawValue: "choice"), .choice) } + func testQuizQuestionTypeFill() { XCTAssertEqual(QuizQuestionType.from(rawValue: "fill"), .fill) } + func testQuizQuestionTypeJudge() { XCTAssertEqual(QuizQuestionType.from(rawValue: "judge"), .judge) } + func testQuizQuestionTypeUnknown() { XCTAssertNil(QuizQuestionType.from(rawValue: "essay")) } + + func testJudgeDisplayAnswer() { + XCTAssertEqual(QuizQuestionType.judge.displayAnswer(raw: "true"), "正确") + XCTAssertEqual(QuizQuestionType.judge.displayAnswer(raw: "false"), "错误") + } + + func testChoiceDisplayAnswer() { + XCTAssertEqual(QuizQuestionType.choice.displayAnswer(raw: "0", options: ["氧气","氮气"]), "氧气") + XCTAssertEqual(QuizQuestionType.choice.displayAnswer(raw: "1", options: ["氧气","氮气"]), "氮气") + } + + func testFillDisplayAnswer() { + XCTAssertEqual(QuizQuestionType.fill.displayAnswer(raw: "用户输入"), "用户输入") + } +}