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 }); }); }); });