Files
math2-platform/backend/tests/unit/streak.calculator.test.ts
Renato bc43c9e772
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled
🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
 Características:
- 45 ejercicios universitarios (Basic → Advanced)
- Renderizado LaTeX profesional
- IA generativa (Z.ai/DashScope)
- Docker 9 servicios
- Tests 123/123 pasando
- Seguridad enterprise (JWT, XSS, Rate limiting)

🐳 Infraestructura:
- Next.js 14 + Node.js 20
- PostgreSQL 15 + Redis 7
- Docker Compose completo
- Nginx + SSL ready

📚 Documentación:
- 5 informes técnicos completos
- README profesional
- Scripts de deployment automatizados

Estado: Producción lista 
2026-03-31 11:27:11 -03:00

486 lines
16 KiB
TypeScript

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