feat: add WeChat sharing and invite flow
This commit is contained in:
@ -23,6 +23,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"user_name": "scenic_operator",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
@ -58,6 +59,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(response.token, "person-temp-token")
|
||||
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
||||
XCTAssertEqual(response.accounts.map(\.subtitle), ["scenic_operator", "store_admin"])
|
||||
}
|
||||
|
||||
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
|
||||
@ -88,6 +90,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
"scenic_user_id": 3,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"user_name": "scenic_operator",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
@ -99,6 +102,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
let user = try JSONDecoder().decode(V9ScenicUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 101)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "scenic_operator")
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101))
|
||||
}
|
||||
|
||||
@ -124,6 +128,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
let user = try JSONDecoder().decode(V9StoreUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 201)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "store_admin")
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
||||
}
|
||||
}
|
||||
|
||||
161
suixinkanTests/InviteTests.swift
Normal file
161
suixinkanTests/InviteTests.swift
Normal file
@ -0,0 +1,161 @@
|
||||
//
|
||||
// InviteTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 邀请 API 和 ViewModel 测试。
|
||||
@MainActor
|
||||
final class InviteTests: XCTestCase {
|
||||
/// 测试邀请 API path 和分页参数。
|
||||
func testInviteAPIUsesExpectedRequests() async throws {
|
||||
let session = MockURLSession(responses: [Self.inviteInfoEnvelope, Self.inviteUsersEnvelope])
|
||||
let api = InviteAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let info = try await api.inviteInfo()
|
||||
let users = try await api.inviteUserList(page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(info.inviteCode, "ABC123")
|
||||
XCTAssertEqual(users.first?.realName, "摄影师")
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/invite-info",
|
||||
"/api/yf-handset-app/photog/invite/user-list",
|
||||
])
|
||||
let query = queryItems(from: session.requests[1])
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
}
|
||||
|
||||
/// 测试邀请信息模型宽松解码。
|
||||
func testInviteInfoResponseDecodesLossyFields() throws {
|
||||
let json = Data(#"{"enable_invite":"1","invite_code":123,"invite_url":"https://invite.example.com","description":["规则1"]}"#.utf8)
|
||||
let response = try JSONDecoder().decode(InviteInfoResponse.self, from: json)
|
||||
|
||||
XCTAssertTrue(response.enableInvite)
|
||||
XCTAssertEqual(response.inviteCode, "123")
|
||||
XCTAssertEqual(response.description, ["规则1"])
|
||||
}
|
||||
|
||||
/// 测试邀请页加载信息并生成二维码。
|
||||
func testPhotographerInviteViewModelLoadsInfoAndGeneratesQRCode() async {
|
||||
let viewModel = PhotographerInviteViewModel()
|
||||
|
||||
await viewModel.reload(api: FakeInviteService())
|
||||
|
||||
XCTAssertEqual(viewModel.inviteCode, "ABC123")
|
||||
XCTAssertEqual(viewModel.inviteUrl, "https://invite.example.com")
|
||||
XCTAssertEqual(viewModel.rules, ["规则1"])
|
||||
XCTAssertNotNil(viewModel.qrImage)
|
||||
XCTAssertFalse(viewModel.isLoading)
|
||||
}
|
||||
|
||||
/// 测试邀请记录首次加载邀请用户和钱包汇总。
|
||||
func testInviteRecordLoadsInviteRowsAndSummary() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.loadInitial(inviteAPI: inviteAPI, walletAPI: walletAPI)
|
||||
|
||||
XCTAssertEqual(viewModel.inviteRows.first?.title, "摄影师")
|
||||
XCTAssertEqual(viewModel.totalRewardText, "100.00")
|
||||
XCTAssertEqual(viewModel.withdrawableText, "50.00")
|
||||
XCTAssertEqual(walletAPI.summaryTypes, [2])
|
||||
XCTAssertFalse(viewModel.canLoadMoreInvite)
|
||||
}
|
||||
|
||||
/// 测试奖励 Tab 加载钱包流水并使用 type=2。
|
||||
func testInviteRecordRewardTabLoadsWalletTransactions() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedTab, .reward)
|
||||
XCTAssertEqual(viewModel.rewardRows.first?.amount, "8.00")
|
||||
XCTAssertEqual(walletAPI.transactionQueries.first?.type, 2)
|
||||
XCTAssertEqual(walletAPI.transactionQueries.first?.page, 1)
|
||||
}
|
||||
|
||||
/// 测试首页菜单 catalog 包含邀请相关 URI。
|
||||
func testHomeMenuCatalogContainsInviteRoutes() {
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "registration_invitation")?.title, "注册邀请")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "photographer_invite")?.title, "邀请摄影师")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "invite_record")?.title, "邀请记录")
|
||||
}
|
||||
|
||||
private static let inviteInfoEnvelope = Data(#"{"code":100000,"msg":"success","data":{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}}"#.utf8)
|
||||
private static let inviteUsersEnvelope = Data(#"{"code":100000,"msg":"success","data":[{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}]}"#.utf8)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 邀请服务测试替身。
|
||||
private final class FakeInviteService: InviteServing {
|
||||
func inviteInfo() async throws -> InviteInfoResponse {
|
||||
InviteInfoResponse(
|
||||
enableInvite: true,
|
||||
inviteCode: "ABC123",
|
||||
inviteUrl: "https://invite.example.com",
|
||||
description: ["规则1"]
|
||||
)
|
||||
}
|
||||
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] {
|
||||
[
|
||||
InviteUserItem(
|
||||
id: 1,
|
||||
realName: "摄影师",
|
||||
phone: "13800000000",
|
||||
avatar: "",
|
||||
createdAt: "2026-06-24",
|
||||
inviteLevel: 1
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 钱包服务测试替身。
|
||||
private final class FakeWalletService: WalletServing {
|
||||
private(set) var summaryTypes: [Int] = []
|
||||
private(set) var transactionQueries: [(page: Int, pageSize: Int, type: Int)] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
|
||||
summaryTypes.append(type)
|
||||
return WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "20.00", amountWithdrawable: "50.00")
|
||||
}
|
||||
|
||||
func walletTransactionList(
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
type: Int
|
||||
) async throws -> WalletTransactionListResponse {
|
||||
transactionQueries.append((page, pageSize, type))
|
||||
return WalletTransactionListResponse(
|
||||
total: 1,
|
||||
list: [
|
||||
WalletTransactionItem(
|
||||
id: 1,
|
||||
amount: "8.00",
|
||||
transactionType: 2,
|
||||
transactionTypeLabel: "邀请奖励",
|
||||
createdAt: "2026-06-24",
|
||||
withdrawLabel: "可提现"
|
||||
),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.reduce(into: [String: String]()) { result, item in
|
||||
result[item.name] = item.value
|
||||
} ?? [:]
|
||||
}
|
||||
69
suixinkanTests/WeChatShareServiceTests.swift
Normal file
69
suixinkanTests/WeChatShareServiceTests.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// WeChatShareServiceTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 微信分享服务测试。
|
||||
@MainActor
|
||||
final class WeChatShareServiceTests: XCTestCase {
|
||||
/// 测试 Universal Link 必须是 HTTPS 且以 `/` 结尾。
|
||||
func testUniversalLinkMustUseHTTPSAndEndWithSlash() {
|
||||
XCTAssertTrue(WeChatConfig.validateUniversalLink(WeChatConfig.universalLink))
|
||||
XCTAssertFalse(WeChatConfig.validateUniversalLink("https://www.zhifly.cn/suixinkan"))
|
||||
XCTAssertFalse(WeChatConfig.validateUniversalLink("http://www.zhifly.cn/suixinkan/"))
|
||||
}
|
||||
|
||||
/// 测试分享场景映射到微信 SDK scene 值。
|
||||
func testShareSceneMapsToWXValues() {
|
||||
XCTAssertEqual(WeChatShareScene.session.wxSceneValue, 0)
|
||||
XCTAssertEqual(WeChatShareScene.timeline.wxSceneValue, 1)
|
||||
XCTAssertEqual(WeChatShareScene.favorite.wxSceneValue, 2)
|
||||
}
|
||||
|
||||
/// 测试微信错误码解析。
|
||||
func testShareResultFromWXErrorCode() {
|
||||
XCTAssertEqual(WeChatShareResult.fromWXErrorCode(0), .success)
|
||||
XCTAssertEqual(WeChatShareResult.fromWXErrorCode(-2), .cancelled)
|
||||
XCTAssertEqual(WeChatShareResult.fromWXErrorCode(99), .unknown(99))
|
||||
}
|
||||
|
||||
/// 测试缩略图压缩到微信限制内。
|
||||
func testThumbImageProcessorKeepsDataUnderLimit() throws {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 640, height: 640))
|
||||
let image = renderer.image { context in
|
||||
UIColor.systemBlue.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 640, height: 640))
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(x: 80, y: 80, width: 480, height: 480))
|
||||
}
|
||||
|
||||
let thumbData = try XCTUnwrap(WeChatThumbImageProcessor.makeThumbData(from: image))
|
||||
XCTAssertLessThanOrEqual(thumbData.count, WeChatThumbImageProcessor.maxThumbBytes)
|
||||
}
|
||||
|
||||
/// 测试空标题和空链接会直接返回参数错误。
|
||||
func testShareWebPageRejectsInvalidPayloadBeforeSending() {
|
||||
var results: [WeChatShareResult] = []
|
||||
WeChatShareService.shareWebPage(
|
||||
WeChatWebPageSharePayload(title: " ", description: "desc", url: "https://example.com"),
|
||||
scene: .session
|
||||
) { result in
|
||||
results.append(result)
|
||||
}
|
||||
WeChatShareService.shareWebPage(
|
||||
WeChatWebPageSharePayload(title: "title", description: "desc", url: " "),
|
||||
scene: .session
|
||||
) { result in
|
||||
results.append(result)
|
||||
}
|
||||
|
||||
XCTAssertEqual(results, [
|
||||
.invalidPayload("分享标题不能为空"),
|
||||
.invalidPayload("分享链接不能为空"),
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user