2230a95b45
- New GravityTransaction schema tracks all gravity changes (registration, interview/optimize/download deduction, purchase, monthly topup, migration) - GravityTopUpService: bulk log for monthly VIP topup - PaymentController.activateMembership: log plan_set transactions - QuotaService: add logTransaction, wire into all gravity-modifying methods - UserService: log registration grants (phone/wx/email/password) - GET /user/gravity-transactions?page=&limit= API - Frontend: user.vue '明细' button + paginated popup - Docs: update PROJECT-STATUS v4.10, FEATURE-LIST, DEPLOYMENT
194 lines
8.7 KiB
TypeScript
194 lines
8.7 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing'
|
|
import { getModelToken } from '@nestjs/mongoose'
|
|
import { PaymentController } from './payment.controller'
|
|
import { WechatPayService } from './wechat-pay.service'
|
|
import { QuotaService } from '../user/quota.service'
|
|
import { PricingService } from '../schemas/pricing.service'
|
|
|
|
describe('PaymentController', () => {
|
|
let controller: PaymentController
|
|
let mockUserModel: any
|
|
let mockOrderModel: any
|
|
let mockWechatPay: any
|
|
let mockQuotaService: any
|
|
let mockPricingService: any
|
|
|
|
const mockUserId = '507f1f77bcf86cd799439011'
|
|
|
|
beforeEach(async () => {
|
|
mockUserModel = {
|
|
findById: jest.fn(),
|
|
findByIdAndUpdate: jest.fn(),
|
|
create: jest.fn(),
|
|
}
|
|
mockOrderModel = {
|
|
findOne: jest.fn(),
|
|
create: jest.fn(),
|
|
}
|
|
mockWechatPay = {
|
|
nativePay: jest.fn().mockResolvedValue({ codeUrl: 'weixin://pay/abc123' }),
|
|
jsapiPay: jest.fn().mockResolvedValue({ paySign: 'mock-sign', nonceStr: 'mock-nonce', package: 'prepay_id=mock', timeStamp: '123456', signType: 'RSA' }),
|
|
queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS', transaction_id: 'wx123' }),
|
|
}
|
|
mockQuotaService = {
|
|
grantCredits: jest.fn().mockResolvedValue(undefined),
|
|
setPlanQuota: jest.fn().mockResolvedValue(undefined),
|
|
}
|
|
mockPricingService = {
|
|
getConfig: jest.fn().mockResolvedValue({
|
|
interview: { pricePerSession: 600, creditsPerPurchase: 1 },
|
|
resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
|
|
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
|
|
plans: {
|
|
growth: { price: 1990, durationDays: 30, gravityPerMonth: 80, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
|
|
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 200, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
|
|
},
|
|
}),
|
|
}
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [PaymentController],
|
|
providers: [
|
|
{ provide: getModelToken('User'), useValue: mockUserModel },
|
|
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
|
{ provide: getModelToken('GravityTransaction'), useValue: { create: jest.fn() } },
|
|
{ provide: WechatPayService, useValue: mockWechatPay },
|
|
{ provide: QuotaService, useValue: mockQuotaService },
|
|
{ provide: PricingService, useValue: mockPricingService },
|
|
],
|
|
}).compile()
|
|
|
|
controller = module.get<PaymentController>(PaymentController)
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe('create', () => {
|
|
it('should throw for invalid plan', async () => {
|
|
await expect(controller.create(mockUserId, 'invalid'))
|
|
.rejects.toThrow('无效套餐')
|
|
})
|
|
|
|
it('should throw if user not found', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
|
await expect(controller.create(mockUserId, 'growth'))
|
|
.rejects.toThrow('用户不存在')
|
|
})
|
|
|
|
it('should throw if user already has plan', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'growth' }) })
|
|
await expect(controller.create(mockUserId, 'growth'))
|
|
.rejects.toThrow('已是会员')
|
|
})
|
|
|
|
it('should create a growth order', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', phone: '13800138000' }) })
|
|
mockOrderModel.create.mockResolvedValue({})
|
|
|
|
const result = await controller.create(mockUserId, 'growth')
|
|
expect(result).toHaveProperty('codeUrl')
|
|
expect(result).toHaveProperty('outTradeNo')
|
|
expect(mockWechatPay.nativePay).toHaveBeenCalled()
|
|
expect(mockOrderModel.create).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should create a sprint order', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', phone: '13800138000' }) })
|
|
mockOrderModel.create.mockResolvedValue({})
|
|
|
|
const result = await controller.create(mockUserId, 'sprint')
|
|
expect(result).toHaveProperty('codeUrl')
|
|
expect(mockWechatPay.nativePay).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('jsapi', () => {
|
|
it('should throw if no wxOpenid', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', wxOpenid: null }) })
|
|
await expect(controller.jsapi(mockUserId, 'growth'))
|
|
.rejects.toThrow('未绑定微信')
|
|
})
|
|
|
|
it('should return JSAPI pay params', async () => {
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', wxOpenid: 'mock-openid', phone: '13800138000' }) })
|
|
mockOrderModel.create.mockResolvedValue({})
|
|
|
|
const result = await controller.jsapi(mockUserId, 'growth')
|
|
expect(result).toHaveProperty('paySign')
|
|
expect(result).toHaveProperty('outTradeNo')
|
|
})
|
|
})
|
|
|
|
describe('checkOrder', () => {
|
|
it('should throw if order not found for user', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
|
await expect(controller.checkOrder('no-such-order', mockUserId))
|
|
.rejects.toThrow('订单不存在')
|
|
})
|
|
|
|
it('should return order status', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth', type: 'membership' }) })
|
|
const result = await controller.checkOrder('ORD123', mockUserId)
|
|
expect(result).toEqual({ status: 'pending', plan: 'growth', type: 'membership' })
|
|
expect(mockOrderModel.findOne).toHaveBeenCalledWith({ outTradeNo: 'ORD123', userId: mockUserId })
|
|
})
|
|
})
|
|
|
|
describe('activate', () => {
|
|
it('should throw if order not found for user', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
|
await expect(controller.activate(mockUserId, 'ORD123'))
|
|
.rejects.toThrow('订单不存在')
|
|
})
|
|
|
|
it('should throw if payment not completed', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth' }) })
|
|
await expect(controller.activate(mockUserId, 'ORD123'))
|
|
.rejects.toThrow('支付未完成')
|
|
})
|
|
|
|
it('should activate growth plan', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth', type: 'membership' }) })
|
|
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, gravity: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) }
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
|
|
|
const result = await controller.activate(mockUserId, 'ORD123')
|
|
expect(result.success).toBe(true)
|
|
expect(result.plan).toBe('growth')
|
|
expect(mockUser.save).toHaveBeenCalled()
|
|
expect(mockUser.plan).toBe('growth')
|
|
expect(mockUser.gravity).toBe(80)
|
|
expect(mockUser.freeOptimizeUsed).toBe(3)
|
|
})
|
|
|
|
it('should activate sprint plan', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint', type: 'membership' }) })
|
|
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, interviewCredits: 1, resumeOptimizeCredits: 0, resumeDownloadCredits: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) }
|
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
|
|
|
const result = await controller.activate(mockUserId, 'ORD123')
|
|
expect(result.success).toBe(true)
|
|
expect(result.plan).toBe('sprint')
|
|
expect(mockUser.sprintRemaining).toBe(10)
|
|
expect(mockUser.save).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('query', () => {
|
|
it('should throw if order not owned by user', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
|
await expect(controller.query('ORD123', mockUserId))
|
|
.rejects.toThrow('订单不存在')
|
|
})
|
|
|
|
it('should query WeChat for order status', async () => {
|
|
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId }) })
|
|
const result = await controller.query('ORD123', mockUserId)
|
|
expect(result).toHaveProperty('trade_state', 'SUCCESS')
|
|
expect(mockWechatPay.queryOrder).toHaveBeenCalledWith('ORD123')
|
|
})
|
|
})
|
|
})
|