feat: 接入微信小程序支付
This commit is contained in:
@@ -17,14 +17,12 @@ import type {
|
||||
PublicUserSummary,
|
||||
} from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileTaskCenterResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
} from '../common/legalDocuments';
|
||||
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
@@ -41,7 +39,9 @@ const {
|
||||
mockBuildReferralCenter,
|
||||
mockBuildTaskCenter,
|
||||
mockClaimRpgProfileTaskReward,
|
||||
mockCreateRpgProfileRechargeOrder,
|
||||
mockGetRpgProfileReferralInviteCenter,
|
||||
mockGetRpgProfileRechargeCenter,
|
||||
mockGetRpgProfileTasks,
|
||||
mockGetRpgProfileWalletLedger,
|
||||
mockRedeemRpgProfileReferralInviteCode,
|
||||
@@ -137,6 +137,88 @@ const {
|
||||
},
|
||||
center: buildClaimedTaskCenter(),
|
||||
})),
|
||||
mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60光点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60光点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [
|
||||
{
|
||||
productId: 'member_month',
|
||||
title: '月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
badgeLabel: '',
|
||||
description: '30天会员',
|
||||
tier: 'month',
|
||||
},
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免光点回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
yearValue: '100',
|
||||
},
|
||||
],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
})),
|
||||
mockCreateRpgProfileRechargeOrder: vi.fn(
|
||||
async (): Promise<CreateProfileRechargeOrderResponse> => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||
center: buildReferralCenter({
|
||||
invitedUsers: [],
|
||||
@@ -219,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
|
||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60光点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60光点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [
|
||||
{
|
||||
productId: 'member_month',
|
||||
title: '月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
badgeLabel: '',
|
||||
description: '30天会员',
|
||||
tier: 'month',
|
||||
},
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免光点回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
yearValue: '100',
|
||||
},
|
||||
],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
})),
|
||||
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})),
|
||||
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
||||
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -906,6 +911,106 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile recharge modal buys points through mock channel outside mini program', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: /60光点/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'mock',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('已到账')).toBeTruthy();
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
const navigateTo = vi.fn((options: { url: string }) => {
|
||||
const url = new URL(`https://mini.test${options.url}`);
|
||||
const requestId = url.searchParams.get('requestId');
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-wechat-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_mp',
|
||||
paidAt: null as string | null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatMiniProgramPayParams: {
|
||||
timeStamp: '1777110165',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx-prepay',
|
||||
signType: 'RSA',
|
||||
paySign: 'signature',
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60光点/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_mp',
|
||||
);
|
||||
});
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||
expect(navigateUrl).toContain('order-wechat-1');
|
||||
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
|
||||
expect(await screen.findByText('支付已提交')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
@@ -1136,22 +1241,29 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
expect(
|
||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||
).toBe(true);
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /反馈/u }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /每日任务/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(within(legalRegion).getByRole('button', { name: /用户协议/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /隐私政策/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /免责声明/u }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /用户协议/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /免责声明/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const recordLink = within(legalRegion).getByRole('link', {
|
||||
name: ICP_RECORD_NUMBER,
|
||||
@@ -1160,7 +1272,9 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
expect(recordLink.getAttribute('target')).toBe('_blank');
|
||||
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
||||
|
||||
await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u }));
|
||||
await user.click(
|
||||
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||
);
|
||||
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1423,7 +1537,8 @@ test('mobile discover keeps baby object match works in edutainment channel only'
|
||||
await user.click(babyObjectMatchButton);
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user