🎓 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 ✅
This commit is contained in:
485
backend/tests/unit/streak.calculator.test.ts
Normal file
485
backend/tests/unit/streak.calculator.test.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user