362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { RewardApplicationService } from '../../src/application/services/reward-application.service';
|
|
import { RewardCalculationService } from '../../src/domain/services/reward-calculation.service';
|
|
import { RewardExpirationService } from '../../src/domain/services/reward-expiration.service';
|
|
import { REWARD_LEDGER_ENTRY_REPOSITORY } from '../../src/domain/repositories/reward-ledger-entry.repository.interface';
|
|
import { REWARD_SUMMARY_REPOSITORY } from '../../src/domain/repositories/reward-summary.repository.interface';
|
|
import { EventPublisherService } from '../../src/infrastructure/kafka/event-publisher.service';
|
|
import { WalletServiceClient } from '../../src/infrastructure/external/wallet-service/wallet-service.client';
|
|
import { RewardLedgerEntry } from '../../src/domain/aggregates/reward-ledger-entry/reward-ledger-entry.aggregate';
|
|
import { RewardSummary } from '../../src/domain/aggregates/reward-summary/reward-summary.aggregate';
|
|
import { RewardSource } from '../../src/domain/value-objects/reward-source.vo';
|
|
import { RightType } from '../../src/domain/value-objects/right-type.enum';
|
|
import { RewardStatus } from '../../src/domain/value-objects/reward-status.enum';
|
|
import { Money } from '../../src/domain/value-objects/money.vo';
|
|
import { Hashpower } from '../../src/domain/value-objects/hashpower.vo';
|
|
|
|
describe('RewardApplicationService (Integration)', () => {
|
|
let service: RewardApplicationService;
|
|
let mockLedgerRepository: any;
|
|
let mockSummaryRepository: any;
|
|
let mockEventPublisher: any;
|
|
let mockWalletService: any;
|
|
let mockCalculationService: any;
|
|
|
|
beforeEach(async () => {
|
|
mockLedgerRepository = {
|
|
save: jest.fn(),
|
|
saveAll: jest.fn(),
|
|
findPendingByUserId: jest.fn(),
|
|
findSettleableByUserId: jest.fn(),
|
|
findExpiredPending: jest.fn(),
|
|
findByUserId: jest.fn(),
|
|
countByUserId: jest.fn(),
|
|
};
|
|
|
|
mockSummaryRepository = {
|
|
getOrCreate: jest.fn(),
|
|
findByUserId: jest.fn(),
|
|
save: jest.fn(),
|
|
};
|
|
|
|
mockEventPublisher = {
|
|
publishAll: jest.fn(),
|
|
};
|
|
|
|
mockWalletService = {
|
|
executeSwap: jest.fn(),
|
|
};
|
|
|
|
mockCalculationService = {
|
|
calculateRewards: jest.fn(),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
RewardApplicationService,
|
|
{
|
|
provide: RewardCalculationService,
|
|
useValue: mockCalculationService,
|
|
},
|
|
{
|
|
provide: RewardExpirationService,
|
|
useValue: { expireOverdueRewards: jest.fn((r) => r) },
|
|
},
|
|
{
|
|
provide: REWARD_LEDGER_ENTRY_REPOSITORY,
|
|
useValue: mockLedgerRepository,
|
|
},
|
|
{
|
|
provide: REWARD_SUMMARY_REPOSITORY,
|
|
useValue: mockSummaryRepository,
|
|
},
|
|
{
|
|
provide: EventPublisherService,
|
|
useValue: mockEventPublisher,
|
|
},
|
|
{
|
|
provide: WalletServiceClient,
|
|
useValue: mockWalletService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<RewardApplicationService>(RewardApplicationService);
|
|
});
|
|
|
|
describe('distributeRewards', () => {
|
|
it('should distribute rewards and update summaries', async () => {
|
|
const params = {
|
|
sourceOrderNo: 'ORDER001',
|
|
sourceUserId: BigInt(100),
|
|
treeCount: 10,
|
|
provinceCode: '440000',
|
|
cityCode: '440100',
|
|
};
|
|
|
|
const mockReward = RewardLedgerEntry.createPending({
|
|
userId: BigInt(200),
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(100)),
|
|
usdtAmount: Money.USDT(500),
|
|
hashpowerAmount: Hashpower.zero(),
|
|
memo: 'Test',
|
|
});
|
|
|
|
const mockSummary = RewardSummary.create(BigInt(200), BigInt(100));
|
|
|
|
mockCalculationService.calculateRewards.mockResolvedValue([mockReward]);
|
|
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
|
|
|
|
await service.distributeRewards(params);
|
|
|
|
expect(mockCalculationService.calculateRewards).toHaveBeenCalledWith(params);
|
|
expect(mockLedgerRepository.saveAll).toHaveBeenCalledWith([mockReward]);
|
|
expect(mockSummaryRepository.save).toHaveBeenCalled();
|
|
expect(mockEventPublisher.publishAll).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('claimPendingRewardsForUser', () => {
|
|
it('should claim pending rewards and move to settleable', async () => {
|
|
const userId = BigInt(100);
|
|
|
|
const pendingReward = RewardLedgerEntry.createPending({
|
|
userId,
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: Money.USDT(500),
|
|
hashpowerAmount: Hashpower.zero(),
|
|
memo: 'Test reward',
|
|
});
|
|
|
|
const mockSummary = RewardSummary.create(userId, BigInt(100));
|
|
mockSummary.addPending(
|
|
Money.USDT(500),
|
|
Hashpower.zero(),
|
|
new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
);
|
|
|
|
mockLedgerRepository.findPendingByUserId.mockResolvedValue([pendingReward]);
|
|
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
|
|
|
|
const result = await service.claimPendingRewardsForUser(userId);
|
|
|
|
expect(result.claimedCount).toBe(1);
|
|
expect(result.totalUsdtClaimed).toBe(500);
|
|
expect(mockLedgerRepository.save).toHaveBeenCalled();
|
|
expect(mockSummaryRepository.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip expired rewards', async () => {
|
|
const userId = BigInt(100);
|
|
|
|
const expiredReward = RewardLedgerEntry.reconstitute({
|
|
id: BigInt(1),
|
|
userId,
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: 500,
|
|
hashpowerAmount: 0,
|
|
rewardStatus: RewardStatus.PENDING,
|
|
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
|
expireAt: new Date(Date.now() - 1000),
|
|
claimedAt: null,
|
|
settledAt: null,
|
|
expiredAt: null,
|
|
memo: 'Expired reward',
|
|
});
|
|
|
|
const mockSummary = RewardSummary.create(userId, BigInt(100));
|
|
|
|
mockLedgerRepository.findPendingByUserId.mockResolvedValue([expiredReward]);
|
|
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
|
|
|
|
const result = await service.claimPendingRewardsForUser(userId);
|
|
|
|
expect(result.claimedCount).toBe(0);
|
|
expect(result.totalUsdtClaimed).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('settleRewards', () => {
|
|
it('should settle rewards and call wallet service', async () => {
|
|
const accountSequence = BigInt(100);
|
|
|
|
const settleableReward = RewardLedgerEntry.reconstitute({
|
|
id: BigInt(1),
|
|
userId: BigInt(100),
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: 500,
|
|
hashpowerAmount: 0,
|
|
rewardStatus: RewardStatus.SETTLEABLE,
|
|
createdAt: new Date(),
|
|
expireAt: null,
|
|
claimedAt: new Date(),
|
|
settledAt: null,
|
|
expiredAt: null,
|
|
memo: 'Test',
|
|
});
|
|
|
|
const mockSummary = RewardSummary.create(BigInt(100), BigInt(100));
|
|
mockSummary.addSettleable(Money.USDT(500), Hashpower.zero());
|
|
|
|
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([settleableReward]);
|
|
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
|
|
mockWalletService.executeSwap.mockResolvedValue({
|
|
success: true,
|
|
receivedAmount: 0.25,
|
|
txHash: '0x123',
|
|
});
|
|
|
|
const result = await service.settleRewards({
|
|
accountSequence,
|
|
settleCurrency: 'BNB',
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.settledUsdtAmount).toBe(500);
|
|
expect(result.receivedAmount).toBe(0.25);
|
|
expect(result.settleCurrency).toBe('BNB');
|
|
expect(mockWalletService.executeSwap).toHaveBeenCalledWith({
|
|
userId: BigInt(100),
|
|
usdtAmount: 500,
|
|
targetCurrency: 'BNB',
|
|
});
|
|
});
|
|
|
|
it('should return error when no settleable rewards', async () => {
|
|
const accountSequence = BigInt(100);
|
|
|
|
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([]);
|
|
|
|
const result = await service.settleRewards({
|
|
accountSequence,
|
|
settleCurrency: 'BNB',
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('没有可结算的收益');
|
|
});
|
|
|
|
it('should handle wallet service failure', async () => {
|
|
const accountSequence = BigInt(100);
|
|
|
|
const settleableReward = RewardLedgerEntry.reconstitute({
|
|
id: BigInt(1),
|
|
userId: BigInt(100),
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: 500,
|
|
hashpowerAmount: 0,
|
|
rewardStatus: RewardStatus.SETTLEABLE,
|
|
createdAt: new Date(),
|
|
expireAt: null,
|
|
claimedAt: new Date(),
|
|
settledAt: null,
|
|
expiredAt: null,
|
|
memo: 'Test',
|
|
});
|
|
|
|
const mockSummary = RewardSummary.create(BigInt(100), BigInt(100));
|
|
mockSummary.addSettleable(Money.USDT(500), Hashpower.zero());
|
|
|
|
mockLedgerRepository.findSettleableByUserId.mockResolvedValue([settleableReward]);
|
|
mockSummaryRepository.getOrCreate.mockResolvedValue(mockSummary);
|
|
mockWalletService.executeSwap.mockResolvedValue({
|
|
success: false,
|
|
error: 'Insufficient liquidity',
|
|
});
|
|
|
|
const result = await service.settleRewards({
|
|
accountSequence,
|
|
settleCurrency: 'BNB',
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Insufficient liquidity');
|
|
});
|
|
});
|
|
|
|
describe('getRewardSummary', () => {
|
|
it('should return reward summary for user', async () => {
|
|
const userId = BigInt(100);
|
|
const mockSummary = RewardSummary.create(userId, BigInt(100));
|
|
|
|
mockSummaryRepository.findByUserId.mockResolvedValue(mockSummary);
|
|
|
|
const result = await service.getRewardSummary(userId);
|
|
|
|
expect(result.pendingUsdt).toBe(0);
|
|
expect(result.settleableUsdt).toBe(0);
|
|
expect(result.settledTotalUsdt).toBe(0);
|
|
expect(result.expiredTotalUsdt).toBe(0);
|
|
});
|
|
|
|
it('should return zero values when no summary exists', async () => {
|
|
const userId = BigInt(100);
|
|
|
|
mockSummaryRepository.findByUserId.mockResolvedValue(null);
|
|
|
|
const result = await service.getRewardSummary(userId);
|
|
|
|
expect(result.pendingUsdt).toBe(0);
|
|
expect(result.settleableUsdt).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getRewardDetails', () => {
|
|
it('should return paginated reward details', async () => {
|
|
const userId = BigInt(100);
|
|
const mockReward = RewardLedgerEntry.reconstitute({
|
|
id: BigInt(1),
|
|
userId,
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: 500,
|
|
hashpowerAmount: 0,
|
|
rewardStatus: RewardStatus.PENDING,
|
|
createdAt: new Date(),
|
|
expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
claimedAt: null,
|
|
settledAt: null,
|
|
expiredAt: null,
|
|
memo: 'Test',
|
|
});
|
|
|
|
mockLedgerRepository.findByUserId.mockResolvedValue([mockReward]);
|
|
mockLedgerRepository.countByUserId.mockResolvedValue(1);
|
|
|
|
const result = await service.getRewardDetails(userId, {}, { page: 1, pageSize: 20 });
|
|
|
|
expect(result.data).toHaveLength(1);
|
|
expect(result.data[0].usdtAmount).toBe(500);
|
|
expect(result.pagination.total).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('getPendingRewards', () => {
|
|
it('should return pending rewards with countdown', async () => {
|
|
const userId = BigInt(100);
|
|
|
|
const pendingReward = RewardLedgerEntry.createPending({
|
|
userId,
|
|
accountSequence: BigInt(100),
|
|
rewardSource: RewardSource.create(RightType.SHARE_RIGHT, 'ORDER001', BigInt(50)),
|
|
usdtAmount: Money.USDT(500),
|
|
hashpowerAmount: Hashpower.zero(),
|
|
memo: 'Test',
|
|
});
|
|
|
|
mockLedgerRepository.findPendingByUserId.mockResolvedValue([pendingReward]);
|
|
|
|
const result = await service.getPendingRewards(userId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].usdtAmount).toBe(500);
|
|
expect(result[0].remainingTimeMs).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|