/** * Streak Calculator Unit Tests * * Tests for: * - Timezone-aware streak calculation * - Streak breaking after 2 days without activity * - Longest streak calculation * - Edge cases: DST, timezone boundaries, multiple exercises same day */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { subDays, startOfDay } from 'date-fns'; import { toZonedTime } from 'date-fns-tz'; // Mock dependencies FIRST before importing vi.mock('../../src/shared/database/prisma.client', () => ({ prisma: { exerciseAttempt: { findMany: vi.fn(), count: vi.fn(), }, }, })); vi.mock('../../src/shared/utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); // Import mocked modules and service import { prisma } from '../../src/shared/database/prisma.client'; import { StreakCalculator } from '../../src/modules/ranking/calculators/streak.calculator'; describe('StreakCalculator', () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); // ============================================ // BASIC STREAK CALCULATION // ============================================ describe('calculateStreak - basic functionality', () => { it('should return streak 0 when no activity', async () => { // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([]) // recentActivity .mockResolvedValueOnce([]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); expect(result.currentStreak).toBe(0); expect(result.isStreakActive).toBe(false); expect(result.lastActivityDate).toBeNull(); }); it('should calculate streak of 1 for activity today', async () => { const today = new Date(); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity .mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); expect(result.currentStreak).toBe(1); expect(result.isStreakActive).toBe(true); expect(result.lastActivityDate).toBeInstanceOf(Date); }); it('should calculate streak of 3 for 3 consecutive days', async () => { const today = new Date(); const yesterday = subDays(today, 1); const twoDaysAgo = subDays(today, 2); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, { createdAt: twoDaysAgo }, ]) // recentActivity .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, { createdAt: twoDaysAgo }, ]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); expect(result.currentStreak).toBe(3); expect(result.isStreakActive).toBe(true); }); }); // ============================================ // STREAK BREAKING // ============================================ describe('calculateStreak - streak breaking', () => { it('should break streak if no activity for 2 days', async () => { const twoDaysAgo = subDays(new Date(), 2); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: twoDaysAgo }]) // recentActivity .mockResolvedValueOnce([{ createdAt: twoDaysAgo }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); expect(result.currentStreak).toBe(0); expect(result.isStreakActive).toBe(false); }); it('should keep streak active if activity yesterday', async () => { const yesterday = subDays(new Date(), 1); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: yesterday }]) // recentActivity .mockResolvedValueOnce([{ createdAt: yesterday }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); expect(result.currentStreak).toBe(1); expect(result.isStreakActive).toBe(true); }); }); // ============================================ // TIMEZONE HANDLING // ============================================ describe('calculateStreak - timezone handling', () => { it('should handle Argentina timezone (UTC-3)', async () => { // Simular actividad a las 23:00 hora local de Argentina (02:00 UTC del día siguiente) const now = new Date(); const activityAt23PM = new Date(now); activityAt23PM.setHours(23, 0, 0, 0); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: activityAt23PM }]) // recentActivity .mockResolvedValueOnce([{ createdAt: activityAt23PM }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'America/Argentina/Buenos_Aires', }); // Debe considerar como actividad "hoy" en timezone local expect(result.currentStreak).toBe(1); expect(result.isStreakActive).toBe(true); }); it('should handle user traveling between timezones', async () => { const today = new Date(); // Mock para calculateStreak (actividad reciente) y getLongestStreak - llamado dos veces (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity for NY .mockResolvedValueOnce([{ createdAt: today }]) // allActivity for NY .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity for Tokyo .mockResolvedValueOnce([{ createdAt: today }]); // allActivity for Tokyo // Usuario viajó de Nueva York a Tokyo const resultNY = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'America/New_York', }); const resultTokyo = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'Asia/Tokyo', }); // Ambos deben mostrar streak activo, posiblemente con diferente currentStreak // dependiendo de la hora local expect(resultNY.isStreakActive).toBe(true); expect(resultTokyo.isStreakActive).toBe(true); }); it('should use UTC as default timezone', async () => { const today = new Date(); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity .mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', // Sin especificar timezone }); expect(result.currentStreak).toBe(1); expect(result.isStreakActive).toBe(true); }); }); // ============================================ // DAYLIGHT SAVING TIME (DST) // ============================================ describe('calculateStreak - DST handling', () => { it('should handle DST transition in New York', async () => { // Simular actividad durante transición DST const today = new Date(); const yesterday = subDays(today, 1); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, ]) // recentActivity .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, ]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'America/New_York', }); // El cálculo debe ser consistente sin importar DST expect(result.currentStreak).toBeGreaterThanOrEqual(1); expect(result.isStreakActive).toBe(true); }); it('should handle DST transition in Europe', async () => { const today = new Date(); const yesterday = subDays(today, 1); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, ]) // recentActivity .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, ]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'Europe/Madrid', }); expect(result.currentStreak).toBeGreaterThanOrEqual(1); expect(result.isStreakActive).toBe(true); }); }); // ============================================ // MULTIPLE EXERCISES SAME DAY // ============================================ describe('calculateStreak - multiple exercises same day', () => { it('should count only one day for multiple exercises', async () => { const today = new Date(); const activity1 = new Date(today); activity1.setHours(9, 0, 0, 0); const activity2 = new Date(today); activity2.setHours(14, 0, 0, 0); const activity3 = new Date(today); activity3.setHours(20, 0, 0, 0); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([ { createdAt: activity1 }, { createdAt: activity2 }, { createdAt: activity3 }, ]) // recentActivity .mockResolvedValueOnce([ { createdAt: activity1 }, { createdAt: activity2 }, { createdAt: activity3 }, ]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); // Debe contar como 1 día, no 3 expect(result.currentStreak).toBe(1); }); }); // ============================================ // LONGEST STREAK // ============================================ describe('getLongestStreak', () => { it('should return 0 when no activity', async () => { (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([]); const result = await StreakCalculator.getLongestStreak('user-1'); expect(result).toBe(0); }); it('should calculate longest streak correctly', async () => { const today = new Date(); const yesterday = subDays(today, 1); const twoDaysAgo = subDays(today, 2); const fiveDaysAgo = subDays(today, 5); const sixDaysAgo = subDays(today, 6); // Streak actual: 3 días (hoy, ayer, anteayer) // Streak histórico: 2 días (hace 5 y 6 días) (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, { createdAt: twoDaysAgo }, { createdAt: fiveDaysAgo }, { createdAt: sixDaysAgo }, ]); const result = await StreakCalculator.getLongestStreak('user-1'); // El streak más largo es 3 expect(result).toBe(3); }); it('should handle single activity', async () => { const today = new Date(); (prisma.exerciseAttempt.findMany as any).mockResolvedValueOnce([ { createdAt: today }, ]); const result = await StreakCalculator.getLongestStreak('user-1'); expect(result).toBe(1); }); }); // ============================================ // HAS ACTIVITY ON DATE // ============================================ describe('hasActivityOnDate', () => { it('should return true if activity exists on date', async () => { const date = new Date(); (prisma.exerciseAttempt.count as any).mockResolvedValueOnce(1); const result = await StreakCalculator.hasActivityOnDate( 'user-1', date, 'UTC' ); expect(result).toBe(true); }); it('should return false if no activity on date', async () => { const date = new Date(); (prisma.exerciseAttempt.count as any).mockResolvedValueOnce(0); const result = await StreakCalculator.hasActivityOnDate( 'user-1', date, 'UTC' ); expect(result).toBe(false); }); }); // ============================================ // DAYS UNTIL BREAK // ============================================ describe('daysUntilStreakBreaks', () => { it('should return 1 day if activity today', async () => { const today = new Date(); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity .mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); // Si actividad fue hoy, tiene hasta mañana (1 día completo) expect(result.daysUntilStreakBreaks).toBe(1); }); it('should return fraction of day if activity yesterday', async () => { const yesterday = subDays(new Date(), 1); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: yesterday }]) // recentActivity .mockResolvedValueOnce([{ createdAt: yesterday }]); // allActivity for longestStreak const result = await StreakCalculator.calculateStreak({ userId: 'user-1', timezone: 'UTC', }); // Si actividad fue ayer, debe actuar hoy (fracción de día) expect(result.daysUntilStreakBreaks).toBeGreaterThan(0); expect(result.daysUntilStreakBreaks).toBeLessThan(1); }); }); // ============================================ // GET USER STREAK INFO (Optimized version) // ============================================ describe('getUserStreakInfo', () => { it('should return simplified streak info', async () => { const today = new Date(); const yesterday = subDays(today, 1); const twoDaysAgo = subDays(today, 2); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, { createdAt: twoDaysAgo }, ]) // recentActivity .mockResolvedValueOnce([ { createdAt: today }, { createdAt: yesterday }, { createdAt: twoDaysAgo }, ]); // allActivity for longestStreak const result = await StreakCalculator.getUserStreakInfo('user-1', 'UTC'); expect(result.currentStreak).toBe(3); expect(result.hasStreakBonus).toBe(true); }); it('should not have streak bonus for less than 3 days', async () => { const today = new Date(); // Mock para calculateStreak (actividad reciente) y getLongestStreak (prisma.exerciseAttempt.findMany as any) .mockResolvedValueOnce([{ createdAt: today }]) // recentActivity .mockResolvedValueOnce([{ createdAt: today }]); // allActivity for longestStreak const result = await StreakCalculator.getUserStreakInfo('user-1', 'UTC'); expect(result.currentStreak).toBe(1); expect(result.hasStreakBonus).toBe(false); }); }); });