✨ 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 ✅
486 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|