170 lines
6.7 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
});
|