feat: add WeChat sharing and invite flow

This commit is contained in:
2026-07-07 16:53:39 +08:00
parent f1937e23d4
commit a80c181bd7
160 changed files with 7557 additions and 849 deletions

View File

@ -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))
}
}

View 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
} ?? [:]
}

View 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("分享链接不能为空"),
])
}
}