duoqi-api/src/__tests__/services/progress/hearts-service.test.ts
Wang Zhuoxuan c29599daaa
All checks were successful
CI/CD Pipeline / Unit Tests (push) Successful in 19s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m15s
fix: clamp negative hearts in bootstrap progress
2026-06-10 13:04:27 +08:00

170 lines
6.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
function selectReturning(rows: unknown[]) {
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
};
}
function updateReturning() {
return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) };
}
describe('hearts-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('constants', () => {
it('MAX_FREE_HEARTS is 5', async () => {
const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js');
expect(MAX_FREE_HEARTS).toBe(5);
});
it('PRO_HEARTS is 99', async () => {
const { PRO_HEARTS } = await import('../../../services/progress/hearts-service.js');
expect(PRO_HEARTS).toBe(99);
});
});
describe('deductHeart', () => {
it('deducts 1 heart for free-tier users with hearts > 1', async () => {
vi.mocked(db.select)
// deductHeart: tier + heartsRemaining
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 3 }]) as never)
// isNewUserProtected: createdAt (4 days ago → not protected)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 4 * 86_400_000).toISOString() }]) as never);
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(true);
expect(result.remaining).toBe(2);
});
it('deducts to 0 for old free-tier users', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(true);
expect(result.remaining).toBe(0);
});
it('returns failure when hearts = 0 for old free-tier users', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 0 }]) as never)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(false);
expect(result.remaining).toBe(0);
});
it('does not deduct for Pro users', async () => {
vi.mocked(db.select).mockReturnValueOnce(
selectReturning([{ tier: 'pro', heartsRemaining: 99 }]) as never,
);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(true);
expect(result.remaining).toBe(99);
expect(db.update).not.toHaveBeenCalled();
});
it('does not deduct for ProPlus users', async () => {
vi.mocked(db.select).mockReturnValueOnce(
selectReturning([{ tier: 'proplus', heartsRemaining: 99 }]) as never,
);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(true);
expect(result.remaining).toBe(99);
expect(db.update).not.toHaveBeenCalled();
});
it('protects new users (≤3 days) with minimum 1 heart', async () => {
vi.mocked(db.select)
// deductHeart: user has 1 heart
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never)
// isNewUserProtected: created 1 day ago
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 1 * 86_400_000).toISOString() }]) as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(false);
expect(result.remaining).toBe(1);
});
it('allows deduction from 2→1 for new users (≤3 days)', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 2 }]) as never)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString() }]) as never);
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result.success).toBe(true);
expect(result.remaining).toBe(1);
});
it('returns failure for non-existent user', async () => {
vi.mocked(db.select).mockReturnValueOnce(
selectReturning([]) as never,
);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('nonexistent');
expect(result.success).toBe(false);
expect(result.remaining).toBe(0);
});
it('treats negative stored hearts as 0 when deducting', async () => {
vi.mocked(db.select)
.mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: -11 }]) as never)
.mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never);
const { deductHeart } = await import('../../../services/progress/hearts-service.js');
const result = await deductHeart('user-1');
expect(result).toEqual({ success: false, remaining: 0 });
expect(db.update).not.toHaveBeenCalled();
});
});
describe('getHearts', () => {
it('clamps and repairs negative stored hearts before bootstrap can expose them', async () => {
vi.mocked(db.select).mockReturnValueOnce(
selectReturning([{ tier: 'free', heartsRemaining: -11, heartsLastRestore: null }]) as never,
);
vi.mocked(db.update).mockReturnValue(updateReturning() as never);
const { getHearts } = await import('../../../services/progress/hearts-service.js');
const result = await getHearts('user-1');
expect(result).toEqual({ remaining: 0, max: 5, lastRestore: null });
expect(db.update).toHaveBeenCalledOnce();
expect(vi.mocked(db.update).mock.results[0]?.value.set).toHaveBeenCalledWith({ heartsRemaining: 0 });
});
});
});