diff --git a/README.md b/README.md index b1b4954..73b812e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1085 @@ -# Prometeo-Back -This repository shows Prometeo's back to implement Gym module +# Prometeo-Back: Smart Gym Management System πŸ‹οΈβ€β™€οΈ + +![Build Status](https://img.shields.io/badge/build-passing-brightgreen) +![Test Coverage](https://img.shields.io/badge/coverage-89%25-brightgreen) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.5-green) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-Azure-blue) +![Java](https://img.shields.io/badge/Java-17-orange) +![License](https://img.shields.io/badge/license-MIT-blue) +![Azure](https://img.shields.io/badge/Azure-Deployed-blue) +![AI Integration](https://img.shields.io/badge/AI-OpenAI%20%7C%20HuggingFace-orange) + +Prometeo-Back is a comprehensive gym management system for the Sports Department at Escuela Colombiana de IngenierΓ­a Julio Garavito. This API provides robust functionality for gym session reservations, equipment management, personalized routines, physical progress tracking, and AI-powered fitness recommendations. + +## Table of Contents πŸ“‹ + +- [Team Members](#team-members) +- [Technologies Used](#technologies-used) +- [Architecture](#architecture) + - [C4 Model](#c4-model) + - [Component Diagram](#component-diagram) +- [Sprints and Development](#sprints-and-development) + - [Sprint 1: Core System & DI/IOC](#sprint-1-core-system--diioc) + - [Sprint 2: CI/CD & Azure Integration](#sprint-2-cicd--azure-integration) + - [Sprint 3: AI Integration](#sprint-3-ai-integration) + - [Sprint 4: Security & Performance](#sprint-4-security--performance) + - [Sprint 5: Frontend Development](#sprint-5-frontend-development) +- [Design Patterns](#design-patterns) + - [Data Transfer Objects (DTO)](#data-transfer-objects-dto) + - [Repository Pattern](#repository-pattern) + - [Service Layer](#service-layer) +- [Project Dependencies](#project-dependencies) +- [Project Configuration](#project-configuration) +- [API Documentation (Swagger)](#api-documentation-swagger) +- [Project Structure](#project-structure) +- [Tests and Coverage](#tests-and-coverage) +- [Contributions](#contributions) + +## Team Members + +- Andersson David SΓ‘nchez MΓ©ndez +- Cristian Santiago Pedraza RodrΓ­guez +- Santiago Botero GarcΓ­a +- Juan AndrΓ©s RodrΓ­guez PeΓ±uela +- Ricardo Andres Ayala Garzon + +## Technologies Used πŸ› οΈ + +- **Java OpenJDK 17**: Main programming language +- **Spring Boot 3.4.5**: Framework for web application development +- **Spring Security**: Security module for authentication and authorization +- **Spring Data JPA**: For integration with PostgreSQL database +- **PostgreSQL on Azure**: Cloud-based relational database +- **OpenAI API**: For AI-powered fitness recommendations +- **HuggingFace API**: For exercise suggestion generation +- **Maven**: Dependency management tool +- **JUnit 5 & Mockito**: Testing frameworks +- **Docker**: Application containerization +- **Azure DevOps**: Agile project management +- **GitHub Actions**: CI/CD pipelines +- **AWS API Gateway**: API management and integration +- **Jacoco**: Code coverage +- **Sonar**: Static code analysis +- **Swagger/OpenAPI**: REST API documentation +- **Lombok**: Boilerplate code reduction +- **Dotenv**: Environment variable management +- **PDFBox & Apache POI**: For document generation and Excel export +- **React & Next.js**: Frontend development with TypeScript + +## Architecture πŸ—οΈ + +The project follows a structured multi-tier architecture: + +![alt text](assets/image.png) + +The architecture is divided into the following layers: + +- **Controllers**: Handle HTTP requests and responses +- **Services**: Contain business logic +- **Repositories**: Interfaces for data access +- **Models**: Domain entities and DTOs +- **Configuration**: Spring Boot and security configurations +- **Exception Handling**: Custom error management +- **AI Integration**: OpenAI and HuggingFace clients + +### C4 Model + +The C4 model provides a clear visualization of the system at different levels of abstraction. + +#### Level 1: System Context Diagram + +![alt text](assets/Gym-Module-C4-Context.png) + +This diagram shows Prometeo as a central system interacting with: + +- Gym users (students and staff) +- Sports Department administrators +- External AI systems (OpenAI, HuggingFace) +- Azure PostgreSQL database + +#### Level 2: Container Diagram + +![alt text](assets/Gym-Module-C4-Containers.png) + +The container diagram shows the high-level technical components: + +- Spring Boot API (Prometeo-Back) +- Azure PostgreSQL Database +- React Frontend (Prometeo-Front) +- AI Services Integration + +#### Level 3: Component Diagram + +![alt text](assets/Gym-Module-C4-Components.png) + +The component diagram details the internal components of the Prometeo-Back API: + +- User Management +- Reservation System +- Exercise & Routine Management +- Physical Progress Tracking +- AI Recommendation Engine +- Notification System + +#### Level 4: Code Diagram + +![alt text](assets/Gym-Module-C4-Code.png) + +The component diagram detail the complete code of the Prometeo-Back API: + +- Model +- Repository +- Service +- ServiceImpl + +#### Level 5: Physical Data Diagram + +![alt text](assets/image10.png) + +The physical data model detail the complete DataBase diagram of the Prometeo-Back API: + +- Entity Relations +- DBdiagram.io + +## Sprints and Development πŸƒβ€β™‚οΈ + +### Sprint 1: Core System & DI/ + +![alt text](assets/image-1.png) + +#### Objectives Achieved: + +- Implementation of Dependency Injection (DI) and Inversion of Control (IoC) using Spring Boot +- Development of the core project structure with layered architecture +- Implementation of data persistence with PostgreSQL on Azure +- Definition of data models: users, routines, gym sessions, equipment, and reservations +- Creation of REST endpoints for CRUD operations on core entities + +#### Model Structure: + +```java +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private String email; + private String password; + private boolean isAdmin; + private boolean isActive; + + @OneToMany(mappedBy = "user") + private List routines; + + @OneToMany(mappedBy = "user") + private List reservations; + + @OneToMany(mappedBy = "user") + private List progressRecords; + + // getters and setters +} + +@Entity +@Table(name = "routines") +public class Routine { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + private String description; + private String difficulty; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "routine") + private List exercises; + + // getters and setters +} +``` + +#### Repository Implementation: + +```java +@Repository +public interface UserRepository extends JpaRepository { + User findByEmail(String email); + List findByIsActiveTrue(); + boolean existsByEmail(String email); +} + +@Repository +public interface RoutineRepository extends JpaRepository { + List findByUserIdAndIsActiveTrue(Long userId); + Optional findByIdAndUserId(Long id, Long userId); +} +``` + +#### Service Layer: + +```java +@Service +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + + @Autowired + public UserServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public User findUserById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new PrometeoExceptions(PrometeoExceptions.USUARIO_NO_ENCONTRADO)); + } + + @Override + public User createUser(User user) { + if (userRepository.existsByEmail(user.getEmail())) { + throw new PrometeoExceptions(PrometeoExceptions.YA_EXISTE_USUARIO); + } + return userRepository.save(user); + } + + // Additional methods +} +``` + +### Sprint 2: CI/CD & Azure Integration + +![alt text](assets/image-2.png) + +#### Objectives Achieved: + +- Configuration of GitHub Actions for CI/CD pipeline +- Implementation of unit and integration tests +- Code quality analysis with SonarCloud and Jacoco (89% coverage) +- Automated deployment to Azure App Service +- Configuration of PostgreSQL database in Azure +- Implementation of environment variables (.env) for secure configuration + +#### GitHub Actions Workflow: + +Two environments were created: test and production. + +The CI/CD workflow deploys to the test environment when a PR is made to the develop branch, and to the production environment when merging to the main branch. + +```yaml +name: CI/CD Pipeline (Test Environment) + +on: + pull_request: + branches: + - develop + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Maven Package + run: mvn clean package -DskipTests + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: prometeo-app + path: target/*.jar + + test: + name: Test + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Maven Verify + run: mvn verify + - name: SonarCloud Analysis + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + + deploy: + name: Deploy to Test + needs: test + runs-on: ubuntu-latest + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: prometeo-app + - name: Deploy to Azure Web App (Test) + uses: azure/webapps-deploy@v2 + with: + app-name: prometeo-test + publish-profile: ${{ secrets.AZURE_TEST_PUBLISH_PROFILE }} + package: '*.jar' +``` + +#### Database Configuration: + +```java +@Configuration +public class DatabaseConfig { + + @Bean + public DataSource dataSource() { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + + String host = getValue(dotenv, "NEON_HOST", "localhost:5432"); + String database = getValue(dotenv, "NEON_DATABASE", "postgres"); + String username = getValue(dotenv, "NEON_USERNAME", "postgres"); + String password = getValue(dotenv, "NEON_PASSWORD", "postgres"); + + String url = "jdbc:postgresql://" + host + "/" + database + "?sslmode=require"; + + return DataSourceBuilder.create() + .url(url) + .username(username) + .password(password) + .driverClassName("org.postgresql.Driver") + .build(); + } + + private String getValue(Dotenv dotenv, String key, String defaultValue) { + String value = dotenv.get(key); + return (value != null) ? value : defaultValue; + } +} +``` + +### Sprint 3: AI Integration + +![alt text](assets/image-3.png) + +#### Objectives Achieved: + +- Integration with OpenAI API for personalized fitness recommendations +- Integration with HuggingFace for exercise suggestion generation +- Implementation of AI-based routine recommendations based on user goals +- Development of progress tracking algorithms +- Creation of dynamic workout plan generation + +#### OpenAI Integration: + +```java +@Component +public class OpenAiClient { + private static final Logger logger = LoggerFactory.getLogger(OpenAiClient.class); + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final String apiKey; + private final String apiUrl; + + public OpenAiClient(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + this.webClient = webClientBuilder.build(); + this.objectMapper = objectMapper; + + // Load variables from .env + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + this.apiKey = getValue(dotenv, "OPEN_AI_TOKEN", "dummy-key"); + this.apiUrl = getValue(dotenv, "OPEN_AI_MODEL", "https://api.openai.com/v1/chat/completions"); + + logger.info("OpenAI client initialized with URL: {}", this.apiUrl); + } + + public String generateRoutineRecommendation(Goal userGoal, PhysicalProgress progress) { + try { + Map requestBody = new HashMap<>(); + List> messages = new ArrayList<>(); + + // System message to set context + messages.add(Map.of( + "role", "system", + "content", "You are a professional fitness trainer recommending workout routines." + )); + + // User message with goal and current progress + messages.add(Map.of( + "role", "user", + "content", String.format( + "Based on my goal of %s and my current measurements (weight: %s kg, height: %s cm), " + + "suggest a personalized routine with specific exercises.", + userGoal.getDescription(), + progress.getWeight(), + progress.getHeight() + ) + )); + + requestBody.put("model", "gpt-4"); + requestBody.put("messages", messages); + + // API call and response processing + // ... implementation details + + return "Personalized routine recommendation based on your goal"; + } catch (Exception e) { + logger.error("Error generating routine recommendation", e); + return "Unable to generate recommendation at this time."; + } + } +} +``` + +#### HuggingFace Integration: + +```java +@Service +public class HuggingFaceClient { + private final HuggingFaceProperties props; + private final HttpClient httpClient = HttpClient.newHttpClient(); + + public HuggingFaceClient(HuggingFaceProperties props) { + this.props = props; + } + + public String queryModel(String input) throws Exception { + String jsonPayload = "{\"inputs\": \"" + input + "\"}"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(props.getModelUrl())) + .header("Authorization", "Bearer " + props.getApiToken()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload, StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + // Process and return response + return response.body(); + } +} +``` + +### Sprint 4: Security & Performance + +![alt text](assets/image-4.png) + +#### Objectives Achieved: + +- Implementation of Spring Security for authentication and authorization +- Password encryption with BCrypt +- Role-based access control (Admin, User) +- JWT implementation for stateless authentication +- Performance optimization with caching and indexes +- Security headers and CORS configuration +- Comprehensive exception handling + +#### Security Configuration: + +```java +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .httpBasic(Customizer.withDefaults()); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +#### CORS Configuration and AWS API Gateway Integration: + +```java +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins( + "http://localhost:3000", + "https://prometeo-front.azurewebsites.net", + "https://api.prometeo.aws.gateway.com" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} +``` + +The API is fully integrated with AWS API Gateway, which serves as a central hub for all API endpoints. This configuration facilitates: + +- Centralized endpoint management for all microservices +- API versioning and documentation +- Proper CORS configuration across environments +- Traffic management and throttling +- Request validation and transformation + +The AWS API Gateway provides a unified interface for frontend applications to connect to our backend services, ensuring consistent security and performance monitoring across all endpoints. + +```java +@Configuration +public class ApiGatewayConfig { + + @Value("${aws.apigateway.endpoint}") + private String apiGatewayEndpoint; + + @Bean + public WebClient webClient() { + return WebClient.builder() + .baseUrl(apiGatewayEndpoint) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + // Additional AWS API Gateway configuration +} +``` + +### Sprint 5: Frontend Development + +![alt text](assets/image12.png) + +#### Objectives Achieved: + +- Development of a modern and responsive UI using React with Next.js (TypeScript) +- Implementation of user-friendly interfaces for gym management +- Integration with backend REST APIs using Axios +- Module organization by functionality: + - Gym Reservation System + - Physical Progress Tracking + - Routine Management + - Statistical Dashboards +- Utilization of reusable components and Higher-Order Components (HOC) +- Implementation of specialized libraries for gym scheduling and calendar functionality + +#### Frontend Tech Stack: + +```typescript +// Example of a React component using TypeScript and Axios +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Routine } from '../types/routine'; +import RoutineCard from '../components/RoutineCard'; + +const RoutineList: React.FC = () => { + const [routines, setRoutines] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchRoutines = async () => { + try { + const response = await axios.get('/api/routines', { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}` + } + }); + + setRoutines(response.data); + setLoading(false); + } catch (error) { + console.error('Error fetching routines:', error); + setLoading(false); + } + }; + + fetchRoutines(); + }, []); + + if (loading) { + return
Loading routines...
; + } + + return ( +
+

Your Routines

+
+ {routines.map(routine => ( + + ))} +
+
+ ); +}; + +export default RoutineList; +``` + +The complete frontend codebase, including detailed documentation and component architecture, can be found in the [Olympus repository](https://github.com/DASarria/Olympus.git). + +## Design Patterns + +### Data Transfer Objects (DTO) + +The project utilizes DTOs to separate domain entities from objects used for data transfer between layers and to the frontend: + +```java +public class UserDTO { + private Long id; + private String name; + private String email; + private Boolean isActive; + private Boolean isAdmin; + + // Constructors, getters, and setters +} + +public class RoutineDTO { + private Long id; + private String name; + private String description; + private String difficulty; + private Long userId; + private List exercises; + + // Constructors, getters, and setters +} +``` + +The DTO pattern provides: + +- **Security**: Prevents exposing sensitive domain entity details +- **Flexibility**: Allows customized data representation +- **Efficiency**: Transfers only necessary data + +### Repository Pattern + +The application implements the Repository pattern using Spring Data JPA: + +```java +@Repository +public interface ReservationRepository extends JpaRepository { + List findByUserId(Long userId); + List findByGymSessionId(Long gymSessionId); + List findByDateBetween(LocalDate startDate, LocalDate endDate); +} + +@Repository +public interface RoutineRepository extends JpaRepository { + List findByUserId(Long userId); + Optional findByIdAndUserId(Long id, Long userId); + List findByDifficulty(String difficulty); +} +``` + +### Service Layer + +The Service layer encapsulates business logic between controllers and repositories: + +```java +@Service +public class RoutineServiceImpl implements RoutineService { + + private final RoutineRepository routineRepository; + private final UserRepository userRepository; + + @Autowired + public RoutineServiceImpl(RoutineRepository routineRepository, UserRepository userRepository) { + this.routineRepository = routineRepository; + this.userRepository = userRepository; + } + + @Override + public Routine createRoutine(Routine routine, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new PrometeoExceptions(PrometeoExceptions.USUARIO_NO_ENCONTRADO)); + + routine.setUser(user); + return routineRepository.save(routine); + } + + @Override + public List getRoutinesByUserId(Long userId) { + return routineRepository.findByUserId(userId); + } + + // Additional methods +} +``` + +## Project Dependencies + +The project uses a variety of dependencies managed by Maven: + +```xml + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-webflux + + + com.squareup.okhttp3 + okhttp + 4.10.0 + + + + + org.apache.pdfbox + pdfbox + 2.0.30 + + + org.apache.poi + poi-ooxml + 5.2.3 + + + + + org.projectlombok + lombok + 1.18.30 + provided + + + io.github.cdimascio + dotenv-java + 3.0.0 + +``` + +## Project Configuration + +### Prerequisites + +- Java OpenJDK 17.x.x +- Apache Maven 3.9.x +- PostgreSQL (local development) +- Azure PostgreSQL (production) +- Docker (optional) + +### Environment Variables + +The application uses dotenv for secure environment variable management: + +```properties +# PostgreSQL Configuration +NEON_HOST=your-azure-postgresql-host.postgres.database.azure.com +NEON_DATABASE=prometeo_db +NEON_USERNAME=admin_user +NEON_PASSWORD=secure_password + +# OpenAI Configuration +OPEN_AI_TOKEN=your-openai-api-key +OPEN_AI_MODEL=https://api.openai.com/v1/chat/completions + +# HuggingFace Configuration +HUGGINGFACE_API_TOKEN=your-huggingface-api-token +HUGGINGFACE_MODEL_URL=https://api-inference.huggingface.co/models/your-model +``` + +### application.properties + +The main configuration is defined in application.properties: + +```properties +spring.application.name=prometeo +# PostgreSQL with Azure configuration +spring.datasource.url=jdbc:postgresql://${NEON_HOST}/${NEON_DATABASE} +spring.datasource.username=${NEON_USERNAME} +spring.datasource.password=${NEON_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA configuration +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# OpenAi configuration +openai.api.key=${OPEN_AI_TOKEN} +openai.api.url=${OPEN_AI_MODEL} + +# SSL configuration +spring.datasource.hikari.properties.ssl=true +spring.datasource.hikari.properties.sslfactory=org.postgresql.ssl.NonValidatingFactory + +# Server configuration +server.port=8081 +``` + +## API Documentation (Swagger) + +The project uses Swagger/OpenAPI for clear API documentation. You can access it at: + +``` +https://crono-d3evb8a9h2cfd2fb.canadacentral-01.azurewebsites.net/swagger-ui/index.html +``` + +Example API endpoint documentation: + +```java +@RestController +@RequestMapping("/api/users") +@CrossOrigin(origins = "*") +@Tag(name = "User Controller", description = "API for managing user profiles, physical tracking, goals, routines, and reservations") +public class UserController { + + @Autowired + private UserService userService; + + @Autowired + private GymReservationService gymReservationService; + + @Autowired + private RoutineRepository routineRepository; + + @Autowired + private RoutineExerciseRepository routineExerciseRepository; + + @Autowired + private BaseExerciseService baseExerciseService; + + @Autowired + private GoalService goalService; + + @Autowired + private ReportService reportService; + + // ----------------------------------------------------- + // User profile endpoints + // ----------------------------------------------------- + + @GetMapping("/{id}") + @Operation(summary = "Get user by ID", description = "Retrieves a user by their unique identifier") + @ApiResponse(responseCode = "200", description = "User found", content = @Content(schema = @Schema(implementation = User.class))) + @ApiResponse(responseCode = "404", description = "User not found") + public ResponseEntity getUserById(@Parameter(description = "User ID") @PathVariable String id) { + return ResponseEntity.ok(userService.getUserById(id)); + } + + // Other endpoints +} +``` + +## Project Structure + +The project follows a clean and organized structure: + +``` +src +β”œβ”€β”€ main +β”‚ β”œβ”€β”€ java +β”‚ β”‚ └── edu +β”‚ β”‚ └── eci +β”‚ β”‚ └── cvds +β”‚ β”‚ └── prometeo +β”‚ β”‚ β”œβ”€β”€ PrometeoApplication.java +β”‚ β”‚ β”œβ”€β”€ PrometeoExceptions.java +β”‚ β”‚ β”œβ”€β”€ config +β”‚ β”‚ β”‚ β”œβ”€β”€ CorsConfig.java +β”‚ β”‚ β”‚ β”œβ”€β”€ DatabaseConfig.java +β”‚ β”‚ β”‚ β”œβ”€β”€ OpenAPIConfig.java +β”‚ β”‚ β”‚ β”œβ”€β”€ SecurityConfig.java +β”‚ β”‚ β”œβ”€β”€ controller +β”‚ β”‚ β”‚ β”œβ”€β”€ AuthController.java +β”‚ β”‚ β”‚ β”œβ”€β”€ EquipmentController.java +β”‚ β”‚ β”‚ β”œβ”€β”€ GymSessionController.java +β”‚ β”‚ β”‚ β”œβ”€β”€ PhysicalProgressController.java +β”‚ β”‚ β”‚ β”œβ”€β”€ ReservationController.java +β”‚ β”‚ β”‚ β”œβ”€β”€ RoutineController.java +β”‚ β”‚ β”‚ └── UserController.java +β”‚ β”‚ β”œβ”€β”€ dto +β”‚ β”‚ β”‚ β”œβ”€β”€ BaseExerciseDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ EquipmentDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ GoalDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ GymSessionDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ PhysicalProgressDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ ReservationDTO.java +β”‚ β”‚ β”‚ β”œβ”€β”€ RoutineDTO.java +β”‚ β”‚ β”‚ └── UserDTO.java +β”‚ β”‚ β”œβ”€β”€ huggingface +β”‚ β”‚ β”‚ β”œβ”€β”€ HuggingFaceClient.java +β”‚ β”‚ β”‚ └── HuggingFaceProperties.java +β”‚ β”‚ β”œβ”€β”€ model +β”‚ β”‚ β”‚ β”œβ”€β”€ BaseExercise.java +β”‚ β”‚ β”‚ β”œβ”€β”€ BodyMeasurements.java +β”‚ β”‚ β”‚ β”œβ”€β”€ Equipment.java +β”‚ β”‚ β”‚ β”œβ”€β”€ Goal.java +β”‚ β”‚ β”‚ β”œβ”€β”€ GymSession.java +β”‚ β”‚ β”‚ β”œβ”€β”€ PhysicalProgress.java +β”‚ β”‚ β”‚ β”œβ”€β”€ Reservation.java +β”‚ β”‚ β”‚ β”œβ”€β”€ Routine.java +β”‚ β”‚ β”‚ β”œβ”€β”€ RoutineExercise.java +β”‚ β”‚ β”‚ β”œβ”€β”€ User.java +β”‚ β”‚ β”‚ └── enums +β”‚ β”‚ β”œβ”€β”€ openai +β”‚ β”‚ β”‚ β”œβ”€β”€ OpenAiClient.java +β”‚ β”‚ β”‚ └── OpenAiProperties.java +β”‚ β”‚ β”œβ”€β”€ repository +β”‚ β”‚ β”‚ β”œβ”€β”€ EquipmentRepository.java +β”‚ β”‚ β”‚ β”œβ”€β”€ GymSessionRepository.java +β”‚ β”‚ β”‚ β”œβ”€β”€ PhysicalProgressRepository.java +β”‚ β”‚ β”‚ β”œβ”€β”€ ReservationRepository.java +β”‚ β”‚ β”‚ β”œβ”€β”€ RoutineRepository.java +β”‚ β”‚ β”‚ └── UserRepository.java +β”‚ β”‚ └── service +β”‚ β”‚ β”œβ”€β”€ EquipmentService.java +β”‚ β”‚ β”œβ”€β”€ GymSessionService.java +β”‚ β”‚ β”œβ”€β”€ PhysicalProgressService.java +β”‚ β”‚ β”œβ”€β”€ ReservationService.java +β”‚ β”‚ β”œβ”€β”€ RoutineService.java +β”‚ β”‚ β”œβ”€β”€ UserService.java +β”‚ β”‚ └── impl +β”‚ β”‚ β”œβ”€β”€ EquipmentServiceImpl.java +β”‚ β”‚ β”œβ”€β”€ GymSessionServiceImpl.java +β”‚ β”‚ β”œβ”€β”€ PhysicalProgressServiceImpl.java +β”‚ β”‚ β”œβ”€β”€ ReservationServiceImpl.java +β”‚ β”‚ β”œβ”€β”€ RoutineServiceImpl.java +β”‚ β”‚ └── UserServiceImpl.java +β”‚ └── resources +β”‚ β”œβ”€β”€ application.properties +β”‚ └── .env +└── test + └── java + └── edu + └── eci + └── cvds + └── prometeo + β”œβ”€β”€ config + β”œβ”€β”€ controller + β”œβ”€β”€ dto + β”œβ”€β”€ model + β”œβ”€β”€ openai + β”œβ”€β”€ repository + └── service +``` + +## Tests and Coverage + +![alt text](assets/image-5.png) + +The project has 89% test coverage, validated using Jacoco. The comprehensive test suite includes: + +- Unit tests for all service implementations +- Integration tests for controllers and repositories +- Mock tests for external API integrations (OpenAI and HuggingFace) +- Security tests for authentication and authorization + +Test reports are automatically generated during CI/CD and analyzed by SonarCloud to ensure code quality and maintenance. + +Example test class: + +```java +@SpringBootTest +class UserServiceImplTest { + + @MockBean + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Test + void findUserByIdShouldReturnUser() { + // Given + Long userId = 1L; + User mockUser = new User(); + mockUser.setId(userId); + mockUser.setName("Test User"); + + when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser)); + + // When + User foundUser = userService.findUserById(userId); + + // Then + assertNotNull(foundUser); + assertEquals(userId, foundUser.getId()); + assertEquals("Test User", foundUser.getName()); + } + + @Test + void findUserByIdShouldThrowExceptionWhenUserNotFound() { + // Given + Long userId = 999L; + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // When & Then + assertThrows(PrometeoExceptions.class, () -> { + userService.findUserById(userId); + }); + } + + // Additional tests +} +``` + +## Contributions + +This project follows Scrum methodology and CI/CD practices: + +1. Developers work in feature branches +2. Pull Requests are required for integrating code into the main branch +3. PRs must pass automated tests and code analysis +4. Code is automatically deployed after successful integration + +To contribute: +1. Create a fork of the project +2. Create a branch for your feature (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +--- + +Developed with ❀️ by the Prometeo team diff --git a/assets/Gym-Module-C4-Code.png b/assets/Gym-Module-C4-Code.png new file mode 100644 index 0000000..59d60e1 Binary files /dev/null and b/assets/Gym-Module-C4-Code.png differ diff --git a/assets/Gym-Module-C4-Components.png b/assets/Gym-Module-C4-Components.png new file mode 100644 index 0000000..e89ce35 Binary files /dev/null and b/assets/Gym-Module-C4-Components.png differ diff --git a/assets/Gym-Module-C4-Containers.png b/assets/Gym-Module-C4-Containers.png new file mode 100644 index 0000000..dd22461 Binary files /dev/null and b/assets/Gym-Module-C4-Containers.png differ diff --git a/assets/Gym-Module-C4-Context.png b/assets/Gym-Module-C4-Context.png new file mode 100644 index 0000000..ebc7aad Binary files /dev/null and b/assets/Gym-Module-C4-Context.png differ diff --git a/assets/image-1.png b/assets/image-1.png new file mode 100644 index 0000000..a7f3e07 Binary files /dev/null and b/assets/image-1.png differ diff --git a/assets/image-2.png b/assets/image-2.png new file mode 100644 index 0000000..2209b95 Binary files /dev/null and b/assets/image-2.png differ diff --git a/assets/image-3.png b/assets/image-3.png new file mode 100644 index 0000000..63e412f Binary files /dev/null and b/assets/image-3.png differ diff --git a/assets/image-4.png b/assets/image-4.png new file mode 100644 index 0000000..adff8ad Binary files /dev/null and b/assets/image-4.png differ diff --git a/assets/image-5.png b/assets/image-5.png new file mode 100644 index 0000000..f561769 Binary files /dev/null and b/assets/image-5.png differ diff --git a/assets/image.png b/assets/image.png new file mode 100644 index 0000000..dff52c2 Binary files /dev/null and b/assets/image.png differ diff --git a/assets/image10.png b/assets/image10.png new file mode 100644 index 0000000..4c91619 Binary files /dev/null and b/assets/image10.png differ diff --git a/assets/image12.png b/assets/image12.png new file mode 100644 index 0000000..5ac6a8f Binary files /dev/null and b/assets/image12.png differ diff --git a/pom.xml b/pom.xml index bdc002c..40078d1 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,21 @@ com.fasterxml.jackson.core jackson-databind - + + + + org.apache.pdfbox + pdfbox + 2.0.30 + + + + + org.apache.poi + poi-ooxml + 5.2.3 + + org.springframework.boot spring-boot-starter-security @@ -171,8 +185,10 @@ dotenv-java 2.3.1 - - + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/src/main/java/edu/eci/cvds/prometeo/config/CorsConfig.java b/src/main/java/edu/eci/cvds/prometeo/config/CorsConfig.java index 3c84d21..831a40b 100644 --- a/src/main/java/edu/eci/cvds/prometeo/config/CorsConfig.java +++ b/src/main/java/edu/eci/cvds/prometeo/config/CorsConfig.java @@ -6,13 +6,12 @@ @Configuration public class CorsConfig implements WebMvcConfigurer { - @Override - public void addCorsMappings(@SuppressWarnings("null") CorsRegistry registry) { + public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("*") // Cambiar el origen al necesario - .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE") + .allowedOrigins("http://localhost:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") - .allowCredentials(false); + .allowCredentials(true); } } \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/config/JwtRequestFilter.java b/src/main/java/edu/eci/cvds/prometeo/config/JwtRequestFilter.java new file mode 100644 index 0000000..1ca9458 --- /dev/null +++ b/src/main/java/edu/eci/cvds/prometeo/config/JwtRequestFilter.java @@ -0,0 +1,80 @@ +package edu.eci.cvds.prometeo.config; + +import edu.eci.cvds.prometeo.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + public JwtRequestFilter(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_OK); + return; + } + + final String authHeader = request.getHeader("Authorization"); + + System.out.println("πŸ” Checking Authorization header..."); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + System.out.println("βœ… Authorization header found: " + authHeader); + + try { + var claims = jwtUtil.extractClaims(authHeader); + + String username = claims.get("userName", String.class); + String role = claims.get("role", String.class).toUpperCase(); + String name = claims.get("name", String.class); + String idCard = claims.get("id", String.class); + + // Log extracted claims + System.out.println("βœ… JWT Claims extracted:"); + System.out.println("username = " + username); + System.out.println("role = " + role); + System.out.println("name = " + name); + System.out.println("idCard = " + idCard); + + // Save attributes in the request + request.setAttribute("username", username); + request.setAttribute("role", role); + request.setAttribute("name", name); + request.setAttribute("institutionalId", idCard); + + // Set authentication in SecurityContext + var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); + var auth = new UsernamePasswordAuthenticationToken(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + } catch (Exception e) { + System.out.println("❌ Error extracting JWT claims: " + e.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token"); + return; + } + } else { + System.out.println("⚠️ Authorization header is missing or does not start with 'Bearer '"); + } + + chain.doFilter(request, response); + System.out.println("πŸ” Post-filter role: " + request.getAttribute("role")); + } +} \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/config/LoggingFilter.java b/src/main/java/edu/eci/cvds/prometeo/config/LoggingFilter.java new file mode 100644 index 0000000..84fb9e6 --- /dev/null +++ b/src/main/java/edu/eci/cvds/prometeo/config/LoggingFilter.java @@ -0,0 +1,24 @@ +package edu.eci.cvds.prometeo.config; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class LoggingFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + System.out.println("πŸ” Request URI: " + request.getRequestURI()); + System.out.println("πŸ” Method: " + request.getMethod()); + System.out.println("πŸ” All Attributes: "); + request.getAttributeNames().asIterator().forEachRemaining(attr -> + System.out.println(attr + " = " + request.getAttribute(attr)) + ); + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/config/SecurityConfig.java b/src/main/java/edu/eci/cvds/prometeo/config/SecurityConfig.java index ba9a8af..cd31946 100644 --- a/src/main/java/edu/eci/cvds/prometeo/config/SecurityConfig.java +++ b/src/main/java/edu/eci/cvds/prometeo/config/SecurityConfig.java @@ -1,26 +1,57 @@ package edu.eci.cvds.prometeo.config; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; + +@Slf4j @Configuration @EnableWebSecurity public class SecurityConfig { - + + private final JwtRequestFilter jwtRequestFilter; + + public SecurityConfig(JwtRequestFilter jwtRequestFilter) { + this.jwtRequestFilter = jwtRequestFilter; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - // ConfiguraciΓ³n que desactiva toda la seguridad http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/**").permitAll() - ) - .formLogin(form -> form.disable()) - .httpBasic(basic -> basic.disable()); - + .cors(cors -> {}) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/users/create").authenticated() + .requestMatchers("/api/users/trainer/sessions").hasAnyRole("STUDENT", "TRAINER") + .requestMatchers("/api/users/trainer/**").hasRole("TRAINER") + .anyRequest().hasAnyRole("TRAINER", "STUDENT", "ADMIN") + ) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new LoggingFilter(), JwtRequestFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/controller/UserController.java b/src/main/java/edu/eci/cvds/prometeo/controller/UserController.java index 9d2b40e..bd80607 100644 --- a/src/main/java/edu/eci/cvds/prometeo/controller/UserController.java +++ b/src/main/java/edu/eci/cvds/prometeo/controller/UserController.java @@ -1,17 +1,18 @@ package edu.eci.cvds.prometeo.controller; import edu.eci.cvds.prometeo.model.*; +import edu.eci.cvds.prometeo.model.enums.ReportFormat; import edu.eci.cvds.prometeo.repository.RoutineExerciseRepository; import edu.eci.cvds.prometeo.repository.RoutineRepository; import edu.eci.cvds.prometeo.service.*; import edu.eci.cvds.prometeo.dto.*; +import jakarta.servlet.http.HttpServletRequest; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -55,7 +56,6 @@ */ @RestController @RequestMapping("/api/users") -@CrossOrigin(origins = "*") @Tag(name = "User Controller", description = "API for managing user profiles, physical tracking, goals, routines, and reservations") public class UserController { @@ -78,6 +78,9 @@ public class UserController { @Autowired private GoalService goalService; + @Autowired + private ReportService reportService; + // ----------------------------------------------------- // User profile endpoints // ----------------------------------------------------- @@ -87,16 +90,26 @@ public class UserController { @ApiResponse(responseCode = "200", description = "User found", content = @Content(schema = @Schema(implementation = User.class))) @ApiResponse(responseCode = "404", description = "User not found") public ResponseEntity getUserById(@Parameter(description = "User ID") @PathVariable String id) { - return ResponseEntity.ok(userService.getUserById(id)); + try { + User user = userService.getUserById(id); + return ResponseEntity.ok(user); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } } @GetMapping("/by-institutional-id/{institutionalId}") @Operation(summary = "Get user by institutional ID", description = "Retrieves a user by their institutional identifier") @ApiResponse(responseCode = "200", description = "User found", content = @Content(schema = @Schema(implementation = User.class))) @ApiResponse(responseCode = "404", description = "User not found") - public ResponseEntity getUserByInstitutionalId( + public ResponseEntity getUserByInstitutionalId( @Parameter(description = "Institutional ID") @PathVariable String institutionalId) { - return ResponseEntity.ok(userService.getUserByInstitutionalId(institutionalId)); + try { + User user = userService.getUserByInstitutionalId(institutionalId); + return ResponseEntity.ok(user); + } catch (RuntimeException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage()); + } } @GetMapping @@ -113,6 +126,53 @@ public ResponseEntity> getUsersByRole( @Parameter(description = "Role name") @PathVariable String role) { return ResponseEntity.ok(userService.getUsersByRole(role)); } + + @PostMapping("/create") + @Operation(summary = "Create user from JWT", description = "Creates a new user using data from the JWT token") + @ApiResponse(responseCode = "201", description = "User created successfully", + content = @Content(schema = @Schema(implementation = User.class))) + @ApiResponse(responseCode = "400", description = "Invalid input data") + @ApiResponse(responseCode = "409", description = "User already exists") + public ResponseEntity createUser(HttpServletRequest request) { + try { + String institutionalId = (String) request.getAttribute("institutionalId"); + String username = (String) request.getAttribute("username"); + String name = (String) request.getAttribute("name"); + String role = (String) request.getAttribute("role"); + + // Log extracted attributes + System.out.println("πŸ” Extracted attributes:"); + System.out.println("institutionalId = " + institutionalId); + System.out.println("username = " + username); + System.out.println("name = " + name); + System.out.println("role = " + role); + + // Validate attributes + if (institutionalId == null || name == null || role == null) { + System.out.println("❌ Missing required attributes"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); + } + + // Check if user already exists + if (userService.userExistsByInstitutionalId(institutionalId)) { + System.out.println("⚠️ User with institutionalId " + institutionalId + " already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(null); + } + + // Create user + UserDTO userDTO = new UserDTO(); + userDTO.setInstitutionalId(institutionalId); + userDTO.setName(name); + userDTO.setRole(role); + + User createdUser = userService.createUser(userDTO); + return new ResponseEntity<>(createdUser, HttpStatus.CREATED); + + } catch (Exception e) { + System.out.println("❌ Error creating user: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); + } +} @PutMapping("/{id}") @Operation(summary = "Update user", description = "Updates a user's basic information") @@ -124,15 +184,6 @@ public ResponseEntity updateUser( return ResponseEntity.ok(userService.updateUser(id, userDTO)); } - @PostMapping - @Operation(summary = "Create user", description = "Creates a new user in the system") - @ApiResponse(responseCode = "201", description = "User created successfully", content = @Content(schema = @Schema(implementation = User.class))) - public ResponseEntity createUser( - @Parameter(description = "User data") @RequestBody UserDTO userDTO) { - User createdUser = userService.createUser(userDTO); - return new ResponseEntity<>(createdUser, HttpStatus.CREATED); - } - @DeleteMapping("/{id}") @Operation(summary = "Delete user", description = "Deletes a user from the system") @ApiResponse(responseCode = "200", description = "User deleted successfully") @@ -816,7 +867,7 @@ public ResponseEntity cancelSession( @GetMapping("/trainer/sessions") @Operation(summary = "Get sessions by date", description = "Retrieves all gym sessions for a specific date") @ApiResponse(responseCode = "200", description = "Sessions retrieved successfully") - @PreAuthorize("hasRole('TRAINER') or hasRole('ADMIN')") + @PreAuthorize("hasRole('TRAINER') or hasRole('ADMIN') or hasRole('STUDENT')") public ResponseEntity> getSessionsByDate( @Parameter(description = "Date to check") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { @@ -832,6 +883,8 @@ public ResponseEntity> getTrainerSessions( @Parameter(description = "Trainer ID") @PathVariable UUID trainerId) { List sessions = gymSessionService.getSessionsByTrainer(trainerId); + System.out.println("πŸ” Accessing /trainer/{trainerId}/sessions endpoint"); + System.out.println("πŸ” Trainer ID: " + trainerId); return ResponseEntity.ok(sessions); } @@ -930,21 +983,145 @@ public ResponseEntity> getAttendanceStatistics( } @GetMapping("/gym/sessions/{sessionId}") -@Operation(summary = "Get session by ID", description = "Retrieves details of a specific gym session") -@ApiResponse(responseCode = "200", description = "Session found") -@ApiResponse(responseCode = "404", description = "Session not found") -public ResponseEntity getSessionById( - @Parameter(description = "Session ID") @PathVariable UUID sessionId) { - - try { - Object session = gymSessionService.getSessionById(sessionId); - return ResponseEntity.ok(session); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + @Operation(summary = "Get session by ID", description = "Retrieves details of a specific gym session") + @ApiResponse(responseCode = "200", description = "Session found") + @ApiResponse(responseCode = "404", description = "Session not found") + public ResponseEntity getSessionById( + @Parameter(description = "Session ID") @PathVariable UUID sessionId) { + + try { + Object session = gymSessionService.getSessionById(sessionId); + return ResponseEntity.ok(session); + } catch (Exception e) { + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } } -} + + // ----------------------------------------------------- + // Reports and analysis endpoints + // ----------------------------------------------------- + + @GetMapping("/user-progress") + @Operation( + summary = "Generate user progress report", + description = "Returns a report with the user's physical progress over time (e.g., weight and goals).", + responses = { + @ApiResponse(responseCode = "200", description = "Report generated successfully", + content = @Content(mediaType = "application/octet-stream")), + @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content) + } + ) + public ResponseEntity getUserProgressReport( + @Parameter(name = "userId", description = "UUID of the user", required = true, in = ParameterIn.QUERY) + @RequestParam UUID userId, + + @Parameter(name = "format", description = "Report format: PDF, XLSX, CSV, JSON", required = true, in = ParameterIn.QUERY) + @RequestParam ReportFormat format + ) { + byte[] report = reportService.generateUserProgressReport(userId, format); + return buildResponse(report, format, "user_progress_report"); + } + + @GetMapping("/gym-usage") + @Operation( + summary = "Generate gym usage report", + description = "Returns statistics about gym usage (reservations, capacity, duration) for a given date range.", + responses = { + @ApiResponse(responseCode = "200", description = "Report generated successfully", content = @Content), + @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content) + } + ) + public ResponseEntity getGymUsageReport( + @Parameter(name = "startDate", description = "Start date in yyyy-MM-dd format", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + + @Parameter(name = "endDate", description = "End date in yyyy-MM-dd format", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + + @Parameter(name = "format", description = "Report format: PDF, XLSX, CSV, JSON", required = true, in = ParameterIn.QUERY) + @RequestParam ReportFormat format + ) { + byte[] report = reportService.generateGymUsageReport(startDate, endDate, format); + return buildResponse(report, format, "gym_usage_report"); + } + + @GetMapping("/attendance") + @Operation( + summary = "Generate attendance report", + description = "Returns daily attendance statistics for the gym within a date range.", + responses = { + @ApiResponse(responseCode = "200", description = "Report generated successfully", content = @Content), + @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content), + @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content) + } + ) + public ResponseEntity getAttendanceReport( + @Parameter(name = "startDate", description = "Start date in yyyy-MM-dd format", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + + @Parameter(name = "endDate", description = "End date in yyyy-MM-dd format", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + + @Parameter(name = "format", description = "Report format: PDF, XLSX, CSV, JSON", required = true, in = ParameterIn.QUERY) + @RequestParam ReportFormat format + ) { + byte[] report = reportService.getAttendanceStatistics(startDate, endDate, format); + return buildResponse(report, format, "attendance_report"); + } + + /** + * Builds an HTTP response with appropriate headers for file download, + * based on the specified report format. + * + *

This method sets the correct Content-Type and + * Content-Disposition headers to allow clients to download + * the report in the requested format (PDF, XLSX, CSV, JSON).

+ * + * @param content the byte array representing the report content + * @param format the format of the report (PDF, XLSX, CSV, JSON) + * @param filenameBase the base name for the file (without extension) + * @return a ResponseEntity with the file content and appropriate headers + */ + private ResponseEntity buildResponse(byte[] content, ReportFormat format, String filenameBase) { + String contentType; + String extension; + + switch (format) { + case PDF -> { + contentType = "application/pdf"; + extension = ".pdf"; + } + case XLSX -> { + contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + extension = ".xlsx"; + } + case CSV -> { + contentType = "text/csv"; + extension = ".csv"; + } + case JSON -> { + contentType = "application/json"; + extension = ".json"; + } + default -> { + contentType = "application/octet-stream"; + extension = ""; + } + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.setContentDisposition(ContentDisposition.attachment() + .filename(filenameBase + extension) + .build()); + + return new ResponseEntity<>(content, headers, HttpStatus.OK); + } + // // ------------------------------------------------------ // // Equipment reservations endpoints // // ----------------------------------------------------- @@ -974,77 +1151,6 @@ public ResponseEntity getSessionById( // @Parameter(description = "Equipment reservation ID") @PathVariable Long // equipmentReservationId); - // // ----------------------------------------------------- - // // Recommendations endpoints - // // ----------------------------------------------------- - - // @GetMapping("/{userId}/recommended-routines") - // @Operation(summary = "Get recommended routines", description = "Retrieves - // personalized routine recommendations for a user") - // public ResponseEntity> - // getRecommendedRoutines(@Parameter(description = "User ID") @PathVariable Long - // userId); - - // @GetMapping("/{userId}/recommended-classes") - // @Operation(summary = "Get recommended classes", description = "Retrieves - // personalized class recommendations for a user") - // public ResponseEntity> - // getRecommendedClasses(@Parameter(description = "User ID") @PathVariable Long - // userId); - - // // ----------------------------------------------------- - // // Reports and analysis endpoints - // // ----------------------------------------------------- - - // @GetMapping("/{userId}/reports/attendance") - // @Operation(summary = "Get attendance report", description = "Generates an - // attendance report for a user") - // public ResponseEntity getUserAttendanceReport( - // @Parameter(description = "User ID") @PathVariable Long userId, - // @Parameter(description = "Start date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - // @Parameter(description = "End date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - - // AttendanceReportDTO attendanceReport = - // reportService.generateAttendanceReport(userId, startDate, endDate); - - // return ResponseEntity.ok(attendanceReport); - // } - - // @GetMapping("/{userId}/reports/physical-evolution") - // @Operation(summary = "Get physical evolution report", description = - // "Generates a physical evolution report for a user") - // public ResponseEntity - // getUserPhysicalEvolutionReport( - // @Parameter(description = "User ID") @PathVariable Long userId, - // @Parameter(description = "Start date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - // @Parameter(description = "End date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - // PhysicalEvolutionReportDTO physicalEvolutionReport = - // reportService.generatePhysicalEvolutionReport(userId, startDate, endDate); - - // return ResponseEntity.ok(physicalEvolutionReport); - // } - - // @GetMapping("/{userId}/reports/routine-compliance") - // @Operation(summary = "Get routine compliance report", description = - // "Generates a routine compliance report for a user") - // public ResponseEntity - // getUserRoutineComplianceReport( - // @Parameter(description = "User ID") @PathVariable Long userId, - // @Parameter(description = "Start date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - // @Parameter(description = "End date") @RequestParam(required = false) - // @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - - // RoutineComplianceReportDTO routineComplianceReport = - // reportService.generateRoutineComplianceReport(userId, startDate, endDate); - - // return ResponseEntity.ok(routineComplianceReport); - // } - // // ----------------------------------------------------- // // Admin/Trainer specific endpoints // // ----------------------------------------------------- diff --git a/src/main/java/edu/eci/cvds/prometeo/model/PhysicalProgress.java b/src/main/java/edu/eci/cvds/prometeo/model/PhysicalProgress.java index ec729bd..398e50b 100644 --- a/src/main/java/edu/eci/cvds/prometeo/model/PhysicalProgress.java +++ b/src/main/java/edu/eci/cvds/prometeo/model/PhysicalProgress.java @@ -26,8 +26,8 @@ public class PhysicalProgress extends AuditableEntity { private LocalDate recordDate; @ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "active_routine_id") -private Routine activeRoutine; + @JoinColumn(name = "active_routine_id") + private Routine activeRoutine; @Embedded private Weight weight; diff --git a/src/main/java/edu/eci/cvds/prometeo/model/User.java b/src/main/java/edu/eci/cvds/prometeo/model/User.java index 486da33..f6e0fba 100644 --- a/src/main/java/edu/eci/cvds/prometeo/model/User.java +++ b/src/main/java/edu/eci/cvds/prometeo/model/User.java @@ -17,7 +17,7 @@ public class User extends BaseEntity { @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; - @Column(name = "instutional_id", unique = true, nullable = false) + @Column(name = "institutional_id", unique = true, nullable = false) private String institutionalId; @Column(name = "name", nullable = false) diff --git a/src/main/java/edu/eci/cvds/prometeo/model/enums/ReportFormat.java b/src/main/java/edu/eci/cvds/prometeo/model/enums/ReportFormat.java new file mode 100644 index 0000000..141acdf --- /dev/null +++ b/src/main/java/edu/eci/cvds/prometeo/model/enums/ReportFormat.java @@ -0,0 +1,8 @@ +package edu.eci.cvds.prometeo.model.enums; + +public enum ReportFormat { + CSV, + PDF, + XLSX, + JSON +} diff --git a/src/main/java/edu/eci/cvds/prometeo/repository/UserRepository.java b/src/main/java/edu/eci/cvds/prometeo/repository/UserRepository.java index c7fd992..c9c78e7 100644 --- a/src/main/java/edu/eci/cvds/prometeo/repository/UserRepository.java +++ b/src/main/java/edu/eci/cvds/prometeo/repository/UserRepository.java @@ -19,6 +19,8 @@ public interface UserRepository extends JpaRepository { List findByRole(String role); Optional findByInstitutionalId(String institutionalId); + boolean existsByInstitutionalId(String institutionalId); + /** * Finds all users assigned to a specific trainer. * diff --git a/src/main/java/edu/eci/cvds/prometeo/service/GoalService.java b/src/main/java/edu/eci/cvds/prometeo/service/GoalService.java index f4f0619..bb2e5f3 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/GoalService.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/GoalService.java @@ -6,9 +6,38 @@ import java.util.Map; import java.util.UUID; +/** + * Service for managing user goals. + * Provides methods to retrieve, add, update, and delete user-defined goals. + */ public interface GoalService { + /** + * Retrieves all goals associated with a specific user. + * + * @param userId The unique identifier of the user. + * @return A list of goals belonging to the user. + */ List getGoalsByUser(UUID userId); + + /** + * Adds new goals to the specified user. + * + * @param userId The unique identifier of the user. + * @param goals A list of goal descriptions to be added. + */ void addUserGoal(UUID userId, List goals); + + /** + * Updates the descriptions of existing goals. + * + * @param updatedGoals A map where the key is the goal ID and the value is the new goal description. + */ void updateUserGoal(Map updatedGoals); + + /** + * Deletes a goal by its unique identifier. + * + * @param goalId The unique identifier of the goal to be deleted. + */ void deleteGoal(UUID goalId); } diff --git a/src/main/java/edu/eci/cvds/prometeo/service/RecommendationService.java b/src/main/java/edu/eci/cvds/prometeo/service/RecommendationService.java index 867e435..c3083fe 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/RecommendationService.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/RecommendationService.java @@ -12,17 +12,18 @@ public interface RecommendationService { /** - * Recommends routines for a user based on their profile and progress - * @param userId ID of the user - * @return List of recommended routines with compatibility scores + * Generates personalized routine recommendations for a specific user. + * + * @param userId the unique identifier of the user */ List> recommendRoutines(UUID userId); /** - * Finds routines from user - * @param userId ID of the user - * @return List of user IDs to similarity scores + * Retrieves the list of routines associated with a specific user. + * + * @param userId the unique identifier of the user + * @return a list of the user's routines */ List findUserRoutines(UUID userId); } \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/service/ReportService.java b/src/main/java/edu/eci/cvds/prometeo/service/ReportService.java index 29cb63b..604ca8d 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/ReportService.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/ReportService.java @@ -1,5 +1,7 @@ package edu.eci.cvds.prometeo.service; +import edu.eci.cvds.prometeo.model.enums.ReportFormat; + import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -12,65 +14,35 @@ public interface ReportService { /** - * Generates a user progress report - * @param userId ID of the user - * @param startDate Start date - * @param endDate End date - * @param format Format of the report - * @return Report data as a JSON-compatible map - */ - // Map generateUserProgressReport(UUID userId, LocalDate startDate, LocalDate endDate, String format); - - /** - * Generates a gym usage report - * @param startDate Start date - * @param endDate End date - * @param groupBy How to group data (day, week, month) - * @param format Format of the report - * @return List of JSON-compatible maps with usage data - */ - List> generateGymUsageReport(LocalDate startDate, LocalDate endDate, String groupBy, String format); - - /** - * Generates a trainer performance report - * @param trainerId Optional trainer ID (null for all trainers) - * @param startDate Start date - * @param endDate End date - * @param format Format of the report - * @return List of JSON-compatible maps with trainer data - */ - // List> generateTrainerReport(Optional trainerId, LocalDate startDate, LocalDate endDate, String format); - - /** - * Gets attendance statistics - * @param startDate Start date - * @param endDate End date - * @return Map of statistics - */ - Map getAttendanceStatistics(LocalDate startDate, LocalDate endDate); - - /** - * Gets routine usage statistics - * @param startDate Start date - * @param endDate End date - * @return Map of routine IDs to usage counts + * Generates a user progress report. + * This report includes the user's physical progress data such as weight and goal. + * + * @param userId ID of the user whose progress data is to be reported. + * @param format Format in which the report will be generated (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the generated report data in the requested format. */ - // Map getRoutineUsageStatistics(LocalDate startDate, LocalDate endDate); + byte[] generateUserProgressReport(UUID userId, ReportFormat format); /** - * Gets progress statistics for a user - * @param userId ID of the user - * @param months Number of months to analyze - * @return Map of statistics + * Generates a gym usage report. + * This report provides details about gym session usage, such as total capacity, reserved spots, and utilization rate, + * for a given date range. + * + * @param startDate The start date of the period for the report. + * @param endDate The end date of the period for the report. + * @param format Format in which the report will be generated (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the generated gym usage report in the requested format. */ - // Map getUserProgressStatistics(UUID userId, int months); + byte[] generateGymUsageReport(LocalDate startDate, LocalDate endDate, ReportFormat format); /** - * Gets gym capacity utilization - * @param startDate Start date - * @param endDate End date - * @param groupBy How to group data (hour, day, week) - * @return Map of time periods to utilization percentages + * Gets attendance statistics for gym sessions within a specific date range. + * This includes data such as the number of attendees for each session. + * + * @param startDate The start date of the period for the statistics. + * @param endDate The end date of the period for the statistics. + * @param format Format in which the statistics will be generated (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the attendance statistics in the requested format. */ - Map getCapacityUtilization(LocalDate startDate, LocalDate endDate, String groupBy); + byte[] getAttendanceStatistics(LocalDate startDate, LocalDate endDate, ReportFormat format); } \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/service/UserService.java b/src/main/java/edu/eci/cvds/prometeo/service/UserService.java index 2e8f85a..81b5e96 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/UserService.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/UserService.java @@ -38,7 +38,7 @@ public interface UserService { // ------------- Operaciones bΓ‘sicas de usuario ------------- - + /** * Obtener usuario por ID * @param id ID del usuario @@ -46,6 +46,13 @@ public interface UserService { */ User getUserById(String institutionalId); + boolean userExistsByInstitutionalId(String institutionalId); + /** + * Obtener usuario por ID de tarjeta de identificaciΓ³n + * @param idCard ID de tarjeta de identificaciΓ³n del usuario + * @return Entidad de usuario + */ + /** * Obtener usuario por ID institucional * @param institutionalId ID institucional del usuario diff --git a/src/main/java/edu/eci/cvds/prometeo/service/impl/GoalServiceImpl.java b/src/main/java/edu/eci/cvds/prometeo/service/impl/GoalServiceImpl.java index 6b8c12d..95f0b81 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/impl/GoalServiceImpl.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/impl/GoalServiceImpl.java @@ -3,7 +3,6 @@ import edu.eci.cvds.prometeo.PrometeoExceptions; import edu.eci.cvds.prometeo.model.Goal; import edu.eci.cvds.prometeo.model.Recommendation; -import edu.eci.cvds.prometeo.model.User; import edu.eci.cvds.prometeo.repository.GoalRepository; import edu.eci.cvds.prometeo.repository.RecommendationRepository; import edu.eci.cvds.prometeo.repository.UserRepository; @@ -17,6 +16,12 @@ import java.util.Map; import java.util.UUID; + +/** + * Implementation of the {@link GoalService} interface. + * Handles the creation, update, retrieval, and soft deletion of user goals, + * and manages the regeneration of routine recommendations accordingly. + */ @Service public class GoalServiceImpl implements GoalService { @Autowired @@ -31,11 +36,24 @@ public class GoalServiceImpl implements GoalService { @Autowired private RecommendationService recommendationService; + /** + * Retrieves all active goals for a specific user. + * + * @param userId The UUID of the user. + * @return A list of the user's active goals. + */ @Override public List getGoalsByUser(UUID userId) { return goalRepository.findByUserIdAndActive(userId, true); } + /** + * Adds new goals to the specified user and regenerates recommendations. + * Existing recommendations are deactivated before new ones are generated. + * + * @param userId The UUID of the user. + * @param goals A list of goal descriptions to add. + */ @Override public void addUserGoal(UUID userId, List goals) { userRepository.findById(userId) @@ -56,7 +74,12 @@ public void addUserGoal(UUID userId, List goals) { recommendationService.recommendRoutines(userId); } - + /** + * Updates the text of existing user goals and regenerates recommendations. + * All current recommendations are deactivated and refreshed. + * + * @param updatedGoals A map of goal IDs and their new descriptions. + */ @Transactional @Override public void updateUserGoal(Map updatedGoals) { @@ -84,6 +107,12 @@ public void updateUserGoal(Map updatedGoals) { recommendationService.recommendRoutines(userId); } + /** + * Soft deletes a goal by setting its active flag to false. + * Also deactivates existing recommendations and generates new ones. + * + * @param goalId The UUID of the goal to delete. + */ @Override public void deleteGoal(UUID goalId) { Goal goal = goalRepository.findById(goalId) diff --git a/src/main/java/edu/eci/cvds/prometeo/service/impl/RecommendationServiceImpl.java b/src/main/java/edu/eci/cvds/prometeo/service/impl/RecommendationServiceImpl.java index 9dc252e..938c90b 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/impl/RecommendationServiceImpl.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/impl/RecommendationServiceImpl.java @@ -5,7 +5,6 @@ import edu.eci.cvds.prometeo.openai.OpenAiClient; import edu.eci.cvds.prometeo.repository.*; import edu.eci.cvds.prometeo.service.RecommendationService; -import edu.eci.cvds.prometeo.huggingface.HuggingFaceClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -14,14 +13,14 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.time.LocalDate; -import java.time.LocalTime; import java.util.*; import java.util.stream.Collectors; + +/** + * Implementation of the {@link RecommendationService} interface. + * This service uses OpenAI to generate personalized routine recommendations for users based on their goals. + */ @Service public class RecommendationServiceImpl implements RecommendationService { @@ -41,6 +40,11 @@ public class RecommendationServiceImpl implements RecommendationService { @Autowired private OpenAiClient openAiClient; + /** + * Generates and saves routine recommendations for a user using their goals and available routines. + * + * @param userId The UUID of the user for whom recommendations are to be generated. + */ @Override public List> recommendRoutines(UUID userId) { User user = userRepository.findById(userId) @@ -60,7 +64,13 @@ public List> recommendRoutines(UUID userId) { return new ArrayList<>(); } } - + /* + * Builds a natural language prompt to send to OpenAI based on user goals and available routines. + * + * @param goals The list of active goals for the user. + * @param allRoutines All available routines in the system. + * @return A formatted string prompt describing goals and routines. + */ private String buildPrompt(List goals, List allRoutines) { StringBuilder prompt = new StringBuilder(); prompt.append("Las metas del usuario son:\n"); @@ -81,35 +91,48 @@ private String buildPrompt(List goals, List allRoutines) { return prompt.toString(); } -private List parseUUIDList(String response) { - List result = new ArrayList<>(); - try { - // Extraer la respuesta del formato JSON de OpenAI - JsonNode responseJson = new ObjectMapper().readTree(response); - String content = responseJson.path("choices").path(0).path("message").path("content").asText(""); - - // Buscar texto que parezca un UUID en la respuesta - Pattern uuidPattern = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", - Pattern.CASE_INSENSITIVE); - Matcher matcher = uuidPattern.matcher(content); - - // AΓ±adir todos los UUIDs encontrados - while (matcher.find() && result.size() < 10) { - try { - UUID uuid = UUID.fromString(matcher.group()); - result.add(uuid); - } catch (IllegalArgumentException e) { - // Ignora los formatos UUID invΓ‘lidos + /* + * Extracts UUIDs from OpenAI response by parsing the JSON and searching for valid UUID patterns. + * + * @param response The raw JSON response from the OpenAI model. + * @return A list of up to 10 UUIDs extracted from the response. + */ + private List parseUUIDList(String response) { + List result = new ArrayList<>(); + try { + // Extraer la respuesta del formato JSON de OpenAI + JsonNode responseJson = new ObjectMapper().readTree(response); + String content = responseJson.path("choices").path(0).path("message").path("content").asText(""); + + // Buscar texto que parezca un UUID en la respuesta + Pattern uuidPattern = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + Pattern.CASE_INSENSITIVE); + Matcher matcher = uuidPattern.matcher(content); + + // AΓ±adir todos los UUIDs encontrados + while (matcher.find() && result.size() < 10) { + try { + UUID uuid = UUID.fromString(matcher.group()); + result.add(uuid); + } catch (IllegalArgumentException e) { + // Ignora los formatos UUID invΓ‘lidos + } } + } catch (Exception e) { + // Log the error + System.err.println("Error parsing OpenAI response: " + e.getMessage()); } - } catch (Exception e) { - // Log the error - System.err.println("Error parsing OpenAI response: " + e.getMessage()); + + return result; } - - return result; -} + /* + * Creates or updates recommendation entities for the user based on routine IDs. + * + * @param routineIds The list of routine UUIDs recommended by the AI. + * @param user The user receiving the recommendations. + * @return A list of maps associating each recommended routine with its weight. + */ private List> buildRecommendations(List routineIds, User user) { List> recommendedRoutines = new ArrayList<>(); for (int i = 0; i < routineIds.size(); i++) { @@ -142,6 +165,12 @@ private List> buildRecommendations(List routineIds, return recommendedRoutines; } + /** + * Retrieves all active recommended routines for a specific user. + * + * @param userId The UUID of the user. + * @return A list of routines recommended to the user. + */ @Override public List findUserRoutines(UUID userId) { userRepository.findById(userId) diff --git a/src/main/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImpl.java b/src/main/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImpl.java index 9cc3aa1..d649f57 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImpl.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImpl.java @@ -1,172 +1,201 @@ -// package edu.eci.cvds.prometeo.service.impl; - -// import edu.eci.cvds.prometeo.service.ReportService; -// import edu.eci.cvds.prometeo.repository.ReservationRepository; -// import edu.eci.cvds.prometeo.repository.UserRoutineRepository; -// import edu.eci.cvds.prometeo.repository.UserRepository; -// import edu.eci.cvds.prometeo.repository.RoutineRepository; -// import edu.eci.cvds.prometeo.model.Reservation; -// import edu.eci.cvds.prometeo.model.UserRoutine; -// import edu.eci.cvds.prometeo.model.Routine; - -// import org.springframework.beans.factory.annotation.Autowired; -// import org.springframework.stereotype.Service; - -// import java.time.LocalDate; -// import java.time.format.DateTimeFormatter; -// import java.util.*; -// import java.util.stream.Collectors; -// import java.util.Optional; -// import java.util.UUID; - -// @Service -// public class ReportServiceImpl implements ReportService { - -// private final ReservationRepository reservationRepository; -// private final UserRoutineRepository userRoutineRepository; -// private final UserRepository userRepository; -// private final RoutineRepository routineRepository; - -// @Autowired -// public ReportServiceImpl( -// ReservationRepository reservationRepository, -// UserRoutineRepository userRoutineRepository, -// UserRepository userRepository, -// RoutineRepository routineRepository -// ) { -// this.reservationRepository = reservationRepository; -// this.userRoutineRepository = userRoutineRepository; -// this.userRepository = userRepository; -// this.routineRepository = routineRepository; -// } - -// // @Override -// // public Map generateUserProgressReport(UUID userId, LocalDate startDate, LocalDate endDate, String format) { -// // // Ejemplo sencillo: solo cuenta rutinas asignadas y reservas hechas en el periodo -// // Map report = new HashMap<>(); -// // List userRoutines = userRoutineRepository.findByUserIdAndAssignmentDateBetween(userId, startDate, endDate); -// // List reservations = reservationRepository.findByUserIdAndDateBetween(userId, startDate, endDate); - -// // report.put("userId", userId); -// // report.put("routinesAssigned", userRoutines.size()); -// // report.put("reservations", reservations.size()); -// // report.put("period", Map.of("start", startDate, "end", endDate)); -// // return report; -// // } - -// @Override -// public List> generateGymUsageReport(LocalDate startDate, LocalDate endDate, String groupBy, String format) { -// List reservations = reservationRepository.findByDateBetween(startDate, endDate); -// Map grouped; -// DateTimeFormatter formatter; -// if ("week".equalsIgnoreCase(groupBy)) { -// formatter = DateTimeFormatter.ofPattern("YYYY-'W'ww"); -// grouped = reservations.stream().collect(Collectors.groupingBy( -// r -> r.getDate().format(formatter), Collectors.counting())); -// } else if ("month".equalsIgnoreCase(groupBy)) { -// formatter = DateTimeFormatter.ofPattern("yyyy-MM"); -// grouped = reservations.stream().collect(Collectors.groupingBy( -// r -> r.getDate().format(formatter), Collectors.counting())); -// } else { -// formatter = DateTimeFormatter.ISO_DATE; -// grouped = reservations.stream().collect(Collectors.groupingBy( -// r -> r.getDate().format(formatter), Collectors.counting())); -// } -// List> report = new ArrayList<>(); -// for (Map.Entry entry : grouped.entrySet()) { -// Map item = new HashMap<>(); -// item.put("period", entry.getKey()); -// item.put("reservations", entry.getValue()); -// report.add(item); -// } -// return report; -// } - -// // @Override -// // public List> generateTrainerReport(Optional trainerId, LocalDate startDate, LocalDate endDate, String format) { -// // List reservations; -// // if (trainerId.isPresent()) { -// // reservations = reservationRepository.findByTrainerIdAndDateBetween(trainerId.get(), startDate, endDate); -// // } else { -// // reservations = reservationRepository.findByDateBetween(startDate, endDate); -// // } -// // List> report = new ArrayList<>(); -// // for (Reservation r : reservations) { -// // Map item = new HashMap<>(); -// // item.put("date", r.getDate()); -// // item.put("userId", r.getUserId()); -// // item.put("trainerId", r.getTrainerId()); -// // item.put("status", r.getStatus()); -// // report.add(item); -// // } -// // return report; -// // } - -// @Override -// public Map getAttendanceStatistics(LocalDate startDate, LocalDate endDate) { -// List reservations = reservationRepository.findByDateBetween(startDate, endDate); -// int attended = 0; -// int missed = 0; -// for (Reservation r : reservations) { -// if (Boolean.TRUE.equals(r.getAttended())) { -// attended++; -// } else { -// missed++; -// } -// } -// Map stats = new HashMap<>(); -// stats.put("attended", attended); -// stats.put("missed", missed); -// stats.put("total", reservations.size()); -// return stats; -// } - -// // @Override -// // public Map getRoutineUsageStatistics(LocalDate startDate, LocalDate endDate) { -// // List userRoutines = userRoutineRepository.findByAssignmentDateBetween(startDate, endDate); -// // Map usage = new HashMap<>(); -// // for (UserRoutine ur : userRoutines) { -// // usage.put(ur.getRoutineId(), usage.getOrDefault(ur.getRoutineId(), 0) + 1); -// // } -// // return usage; -// // } - -// // @Override -// // public Map getUserProgressStatistics(UUID userId, int months) { -// // LocalDate now = LocalDate.now(); -// // LocalDate from = now.minusMonths(months); -// // List userRoutines = userRoutineRepository.findByUserIdAndAssignmentDateBetween(userId, from, now); -// // Map stats = new HashMap<>(); -// // stats.put("routinesAssigned", userRoutines.size()); -// // stats.put("period", Map.of("start", from, "end", now)); -// // return stats; -// // } - -// @Override -// public Map getCapacityUtilization(LocalDate startDate, LocalDate endDate, String groupBy) { -// List reservations = reservationRepository.findByDateBetween(startDate, endDate); -// Map countByGroup = new HashMap<>(); -// Map capacityByGroup = new HashMap<>(); -// DateTimeFormatter formatter; -// if ("day".equalsIgnoreCase(groupBy)) { -// formatter = DateTimeFormatter.ISO_DATE; -// } else if ("week".equalsIgnoreCase(groupBy)) { -// formatter = DateTimeFormatter.ofPattern("YYYY-'W'ww"); -// } else { -// formatter = DateTimeFormatter.ofPattern("YYYY-MM"); -// } -// for (Reservation r : reservations) { -// String key = r.getDate().format(formatter); -// countByGroup.put(key, countByGroup.getOrDefault(key, 0) + 1); -// // Para demo, capacidad fija de 10 por grupo -// capacityByGroup.put(key, 10); -// } -// Map utilization = new HashMap<>(); -// for (String key : countByGroup.keySet()) { -// int used = countByGroup.get(key); -// int cap = capacityByGroup.getOrDefault(key, 10); -// utilization.put(key, cap == 0 ? 0.0 : (used * 100.0 / cap)); -// } -// return utilization; -// } -// } \ No newline at end of file +package edu.eci.cvds.prometeo.service.impl; + +import edu.eci.cvds.prometeo.model.GymSession; +import edu.eci.cvds.prometeo.model.PhysicalProgress; +import edu.eci.cvds.prometeo.repository.*; +import edu.eci.cvds.prometeo.service.ReportService; +import edu.eci.cvds.prometeo.model.enums.ReportFormat; + +import edu.eci.cvds.prometeo.service.report.ReportGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.time.LocalDate; +import java.util.*; +import java.util.function.Function; + +/** + * Implementation of the ReportService interface. + * This service generates various reports including user progress, gym usage, and attendance statistics. + */ +@Service +public class ReportServiceImpl implements ReportService { + @Autowired + private PhysicalProgressRepository physicalProgressRepository; + @Autowired + private GymSessionRepository gymSessionRepository; + + private final ReportGenerator reportGenerator = new ReportGenerator(); + + /** + * Generates a report on user progress (weight and goal data). + * + * @param userId ID of the user whose progress data is to be reported. + * @param format The desired format for the report (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the report data in the requested format. + */ + @Override + public byte[] generateUserProgressReport(UUID userId, ReportFormat format) { + List data = physicalProgressRepository.findByUserIdOrderByRecordDateDesc(userId); + + List headers = List.of("Fecha", "Peso", "Meta"); + Function> rowMapper = p -> List.of( + p.getRecordDate().toString(), + p.getWeight() != null ? String.valueOf(p.getWeight().getValue()) : "N/A", + p.getPhysicalGoal() != null ? p.getPhysicalGoal() : "N/A" + ); + + Function lineMapper = p -> + "Fecha: " + p.getRecordDate() + + " | Peso: " + (p.getWeight() != null ? p.getWeight().getValue() + "kg" : "N/A") + + " | Meta: " + (p.getPhysicalGoal() != null ? p.getPhysicalGoal() : "N/A"); + + try { + return switch (format) { + case PDF -> reportGenerator.generatePDF(data, "Reporte de Progreso FΓ­sico", lineMapper); + case XLSX -> reportGenerator.generateXLSX(data, headers, rowMapper); + case CSV -> reportGenerator.generateCSV(data, headers, rowMapper); + case JSON -> reportGenerator.generateJSON(data); + }; + } catch (IOException e) { + throw new RuntimeException("Error generando reporte en formato: " + format, e); + } + } + + /** + * Generates a gym usage report. + * + * @param startDate The start date of the period for the gym usage report. + * @param endDate The end date of the period for the gym usage report. + * @param format The desired format for the report (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the gym usage report data in the requested format. + */ + @Override + public byte[] generateGymUsageReport(LocalDate startDate, LocalDate endDate, ReportFormat format) { + List sessions = gymSessionRepository.findBySessionDateBetween(startDate, endDate); + + Map metrics = generateMetrics(sessions, startDate, endDate); + List> reportData = List.of(metrics); + List headers = List.of("Fecha", "Capacidad Total", "Reservas Totales", "Tasa de UtilizaciΓ³n", "UtilizaciΓ³n Promedio", "DuraciΓ³n Promedio"); + + Function> rowMapper = this::mapRow; + Function lineMapper = this::mapLine; + + try { + return switch (format) { + case PDF -> reportGenerator.generatePDF(sessions, "Reporte de Uso del Gimnasio", lineMapper); + case XLSX -> reportGenerator.generateXLSX(sessions, headers, rowMapper); + case CSV -> reportGenerator.generateCSV(sessions, headers, rowMapper); + case JSON -> reportGenerator.generateJSON(reportData); + }; + } catch (IOException e) { + throw new RuntimeException("Error generando reporte en formato: " + format, e); + } + } + + /* + * Generates metrics for the gym usage report. + * + * @param sessions List of gym sessions to generate metrics from. + * @param startDate The start date of the period for the metrics. + * @param endDate The end date of the period for the metrics. + * @return A map containing key metrics (total sessions, total capacity, total reserved spots, etc.). + */ + private Map generateMetrics(List sessions, LocalDate startDate, LocalDate endDate) { + long totalSessions = sessions.size(); + int totalCapacity = sessions.stream().mapToInt(GymSession::getCapacity).sum(); + int totalReserved = sessions.stream().mapToInt(GymSession::getReservedSpots).sum(); + double utilizationRate = totalCapacity > 0 ? (totalReserved * 100.0 / totalCapacity) : 0; + double avgUtilization = sessions.isEmpty() ? 0.0 : sessions.stream() + .mapToDouble(s -> s.getReservedSpots() * 100.0 / s.getCapacity()) + .average().orElse(0.0); + double avgDuration = sessions.isEmpty() ? 0.0 : sessions.stream() + .mapToLong(s -> s.getDuration().toMinutes()) + .average().orElse(0.0); + + return Map.of( + "startDate", startDate.toString(), + "endDate", endDate.toString(), + "totalSessions", totalSessions, + "totalCapacity", totalCapacity, + "totalReservedSpots", totalReserved, + "utilizationRate", String.format("%.2f", utilizationRate) + "%", + "averageUtilizationPerSession", String.format("%.2f", avgUtilization) + "%", + "averageSessionDurationMinutes", String.format("%.2f", avgDuration) + ); + } + + /* + * Maps a gym session to a row of data for the report. + * + * @param session The gym session to map. + * @return A list of strings representing the session data for the report. + */ + private List mapRow(GymSession session) { + return List.of( + session.getSessionDate().toString(), + String.valueOf(session.getCapacity()), + String.valueOf(session.getReservedSpots()), + String.format("%.2f", session.getReservedSpots() * 100.0 / session.getCapacity()) + "%", + String.format("%.2f", session.getReservedSpots() * 100.0 / session.getCapacity()), + String.format("%.2f", session.getDuration().toMinutes()) + ); + } + + /* + * Maps a gym session to a line of data for the report. + * + * @param session The gym session to map. + * @return A string representing the session data for the report. + */ +private String mapLine(GymSession session) { + return String.format( + "Fecha: %s | Capacidad Total: %d | Reservas Totales: %d | Tasa de UtilizaciΓ³n: %.2f%% | UtilizaciΓ³n Promedio: %.2f%% | DuraciΓ³n Promedio: %d minutos", + session.getSessionDate(), session.getCapacity(), session.getReservedSpots(), + session.getReservedSpots() * 100.0 / session.getCapacity(), + session.getReservedSpots() * 100.0 / session.getCapacity(), + session.getDuration().toMinutes() + ); +} + + + /** + * Generates attendance statistics for the gym sessions within a given date range. + * + * @param startDate The start date of the period for the attendance statistics. + * @param endDate The end date of the period for the attendance statistics. + * @param format The desired format for the statistics report (e.g., PDF, XLSX, CSV, JSON). + * @return A byte array containing the attendance statistics in the requested format. + */ + @Override + public byte[] getAttendanceStatistics(LocalDate startDate, LocalDate endDate, ReportFormat format) { + List sessions = gymSessionRepository.findBySessionDateBetween(startDate, endDate); + Map attendanceStats = new HashMap<>(); + for (GymSession session : sessions) { + attendanceStats.put(session.getSessionDate(), session.getReservedSpots()); + } + List headers = List.of("Fecha", "Asistencias"); + Function, List> rowMapper = entry -> List.of( + entry.getKey().toString(), + String.valueOf(entry.getValue()) + ); + + Function, String> lineMapper = entry -> + "Fecha: " + entry.getKey() + " | Asistencias: " + entry.getValue(); + + try { + return switch (format) { + case PDF -> + reportGenerator.generatePDF(attendanceStats.entrySet().stream().toList(), "Reporte de Asistencia al Gimnasio", lineMapper); + case XLSX -> + reportGenerator.generateXLSX(attendanceStats.entrySet().stream().toList(), headers, rowMapper); + case CSV -> + reportGenerator.generateCSV(attendanceStats.entrySet().stream().toList(), headers, rowMapper); + case JSON -> reportGenerator.generateJSON(Collections.singletonList(attendanceStats)); + }; + } catch (IOException e) { + throw new RuntimeException("Error generando reporte en formato: " + format, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/edu/eci/cvds/prometeo/service/impl/UserServiceImpl.java b/src/main/java/edu/eci/cvds/prometeo/service/impl/UserServiceImpl.java index b181955..1dcb147 100644 --- a/src/main/java/edu/eci/cvds/prometeo/service/impl/UserServiceImpl.java +++ b/src/main/java/edu/eci/cvds/prometeo/service/impl/UserServiceImpl.java @@ -85,6 +85,10 @@ public User getUserById(String institutionalId) { .orElseThrow(() -> new RuntimeException("User not found with id: " + institutionalId)); } + @Override + public boolean userExistsByInstitutionalId(String institutionalId){ + return userRepository.existsByInstitutionalId(institutionalId); + } @Override public User getUserByInstitutionalId(String institutionalId) { return userRepository.findByInstitutionalId(institutionalId) diff --git a/src/main/java/edu/eci/cvds/prometeo/service/report/ReportGenerator.java b/src/main/java/edu/eci/cvds/prometeo/service/report/ReportGenerator.java new file mode 100644 index 0000000..5f3396f --- /dev/null +++ b/src/main/java/edu/eci/cvds/prometeo/service/report/ReportGenerator.java @@ -0,0 +1,133 @@ +package edu.eci.cvds.prometeo.service.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.eci.cvds.prometeo.model.PhysicalProgress; +import org.apache.pdfbox.pdmodel.*; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.function.Function; + +/** + * ReportGenerator is a utility component for generating reports in different formats such as + * JSON, CSV, XLSX (Excel), and PDF. It provides generic methods to serialize and format data, + * allowing reuse across various types of entities and data models. + */ +@Component +public class ReportGenerator { + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Generates a JSON report from a list of data objects. + * + * @param data The list of data to serialize. + * @param The type of the objects in the list. + * @return A byte array representing the JSON content. + * @throws IOException If serialization fails. + */ + public byte[] generateJSON(List data) throws IOException { + return objectMapper.writeValueAsBytes(data); + } + + /** + * Generates a CSV report from a list of data objects. + * + * @param data The list of data to serialize. + * @param headers The list of headers to include as the first row. + * @param rowMapper A function that maps each object to a list of string values. + * @param The type of the objects in the list. + * @return A byte array representing the CSV content. + */ + public byte[] generateCSV(List data, List headers, Function> rowMapper) { + StringBuilder builder = new StringBuilder(); + builder.append(String.join(",", headers)).append("\n"); + for (T item : data) { + builder.append(String.join(",", rowMapper.apply(item))).append("\n"); + } + return builder.toString().getBytes(); + } + + /** + * Generates an Excel (XLSX) report from a list of data objects. + * + * @param data The list of data to include. + * @param headers The column headers. + * @param rowMapper A function that maps each object to a list of string values for each column. + * @param The type of the objects in the list. + * @return A byte array representing the Excel file. + * @throws IOException If an error occurs during file writing. + */ + public byte[] generateXLSX(List data, List headers, Function> rowMapper) throws IOException { + try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + Sheet sheet = workbook.createSheet("Reporte"); + Row headerRow = sheet.createRow(0); + for (int i = 0; i < headers.size(); i++) { + headerRow.createCell(i).setCellValue(headers.get(i)); + } + + int rowIdx = 1; + for (T item : data) { + Row row = sheet.createRow(rowIdx++); + List values = rowMapper.apply(item); + for (int i = 0; i < values.size(); i++) { + row.createCell(i).setCellValue(values.get(i)); + } + } + + workbook.write(out); + return out.toByteArray(); + } + } + + /** + * Generates a PDF report from a list of data objects. + * + * @param data The list of data to include in the report. + * @param title The title of the PDF document. + * @param lineMapper A function that maps each object to a string to be rendered as a line in the PDF. + * @param The type of the objects in the list. + * @return A byte array representing the PDF content. + * @throws IOException If an error occurs during PDF generation. + */ + public byte[] generatePDF(List data, String title, Function lineMapper) throws IOException { + try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + + PDPageContentStream content = new PDPageContentStream(doc, page); + content.beginText(); + content.setFont(PDType1Font.HELVETICA_BOLD, 14); + content.newLineAtOffset(50, 700); + content.showText(title); + content.endText(); + + int y = 680; + for (T item : data) { + content.beginText(); + content.setFont(PDType1Font.HELVETICA, 10); + content.newLineAtOffset(50, y); + content.showText(lineMapper.apply(item)); + content.endText(); + y -= 15; + if (y < 50) { + content.close(); + page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + content = new PDPageContentStream(doc, page); + y = 700; + } + } + + content.close(); + doc.save(out); + return out.toByteArray(); + } + } +} diff --git a/src/main/java/edu/eci/cvds/prometeo/util/JwtUtil.java b/src/main/java/edu/eci/cvds/prometeo/util/JwtUtil.java new file mode 100644 index 0000000..427f7ad --- /dev/null +++ b/src/main/java/edu/eci/cvds/prometeo/util/JwtUtil.java @@ -0,0 +1,18 @@ +package edu.eci.cvds.prometeo.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + + private final String SECRET_KEY = "supersecretpassword1234567891011121314"; // Debe ser la misma que usa el microservicio de usuarios + + public Claims extractClaims(String token) { + return Jwts.parser() + .setSigningKey(SECRET_KEY.getBytes()) + .parseClaimsJws(token.replace("Bearer ", "")) + .getBody(); + } +} diff --git a/src/test/java/edu/eci/cvds/prometeo/controller/UserControllerTest.java b/src/test/java/edu/eci/cvds/prometeo/controller/UserControllerTest.java index abf2d44..9dbf71a 100644 --- a/src/test/java/edu/eci/cvds/prometeo/controller/UserControllerTest.java +++ b/src/test/java/edu/eci/cvds/prometeo/controller/UserControllerTest.java @@ -2,17 +2,20 @@ import edu.eci.cvds.prometeo.dto.*; import edu.eci.cvds.prometeo.model.*; +import edu.eci.cvds.prometeo.model.enums.ReportFormat; import edu.eci.cvds.prometeo.repository.RoutineExerciseRepository; import edu.eci.cvds.prometeo.repository.RoutineRepository; import edu.eci.cvds.prometeo.service.*; - +import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import java.time.LocalDate; import java.time.LocalDateTime; @@ -47,6 +50,9 @@ class UserControllerTest { @Mock private GymSessionService gymSessionService; + @Mock + private ReportService reportService; + @InjectMocks private UserController userController; @@ -81,7 +87,7 @@ void testGetUserById() { public void testGetUserByInstitutionalId() { when(userService.getUserByInstitutionalId(anyString())).thenReturn(testUser); - ResponseEntity response = userController.getUserByInstitutionalId("A12345"); + ResponseEntity response = (ResponseEntity) userController.getUserByInstitutionalId("A12345"); assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(testUser, response.getBody()); @@ -111,17 +117,52 @@ public void testGetUsersByRole() { assertEquals(users, response.getBody()); verify(userService).getUsersByRole("STUDENT"); } - @Test - void testCreateUser() { - // Use the exact object instead of any() - when(userService.createUser(userDTO)).thenReturn(testUser); - - ResponseEntity response = userController.createUser(userDTO); - + + @Test + void createUserSuccessfully() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getAttribute("institutionalId")).thenReturn("A12345"); + when(request.getAttribute("username")).thenReturn("testuser"); + when(request.getAttribute("name")).thenReturn("Test User"); + when(request.getAttribute("role")).thenReturn("USER"); + + UserDTO userDTO = new UserDTO(); + userDTO.setInstitutionalId("A12345"); + userDTO.setName("Test User"); + userDTO.setRole("USER"); + + User createdUser = new User(); + createdUser.setInstitutionalId("A12345"); + createdUser.setName("Test User"); + createdUser.setRole("USER"); + + when(userService.userExistsByInstitutionalId("A12345")).thenReturn(false); + when(userService.createUser(userDTO)).thenReturn(createdUser); + + ResponseEntity response = userController.createUser(request); + assertEquals(HttpStatus.CREATED, response.getStatusCode()); - assertEquals(testUser, response.getBody()); + assertEquals(createdUser, response.getBody()); + verify(userService).userExistsByInstitutionalId("A12345"); verify(userService).createUser(userDTO); } + + @Test + void createUserFailsWhenAttributesAreMissing() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getAttribute("institutionalId")).thenReturn(null); + when(request.getAttribute("name")).thenReturn("Test User"); + when(request.getAttribute("role")).thenReturn("USER"); + + ResponseEntity response = userController.createUser(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + verify(userService, never()).userExistsByInstitutionalId(anyString()); + verify(userService, never()).createUser(any(UserDTO.class)); + } + + + @Test void testUpdateUser() { // Use exact matches instead of any() @@ -1432,4 +1473,105 @@ public void testLambdaCreateCustomRoutine() { // Verify the lambda did the transformation correctly verify(userService).createCustomRoutine(eq(userId), any(Routine.class)); } + + @Test + public void testGetUserProgressReport() { + // Prepare test data + UUID userId = UUID.randomUUID(); + ReportFormat format = ReportFormat.PDF; + byte[] mockReportData = "mock report data".getBytes(); + + when(reportService.generateUserProgressReport(userId, format)).thenReturn(mockReportData); + + ResponseEntity response = userController.getUserProgressReport(userId, format); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockReportData, response.getBody()); + + // Verify the correct content type is set for PDF + HttpHeaders headers = response.getHeaders(); + assertEquals(MediaType.APPLICATION_PDF, headers.getContentType()); + assertTrue(headers.getContentDisposition().toString().contains("attachment")); + assertTrue(headers.getContentDisposition().toString().contains("user_progress_report.pdf")); + + verify(reportService).generateUserProgressReport(userId, format); + } + + @Test + public void testGetGymUsageReport() { + // Prepare test data + LocalDate startDate = LocalDate.now().minusMonths(1); + LocalDate endDate = LocalDate.now(); + ReportFormat format = ReportFormat.XLSX; + byte[] mockReportData = "mock gym usage report data".getBytes(); + + when(reportService.generateGymUsageReport(startDate, endDate, format)).thenReturn(mockReportData); + + ResponseEntity response = userController.getGymUsageReport(startDate, endDate, format); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockReportData, response.getBody()); + + // Verify the correct content type is set for XLSX + HttpHeaders headers = response.getHeaders(); + assertEquals(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + headers.getContentType()); + assertTrue(headers.getContentDisposition().toString().contains("attachment")); + assertTrue(headers.getContentDisposition().toString().contains("gym_usage_report.xlsx")); + + verify(reportService).generateGymUsageReport(startDate, endDate, format); + } + + @Test + public void testGetAttendanceReport() { + // Prepare test data + LocalDate startDate = LocalDate.now().minusMonths(1); + LocalDate endDate = LocalDate.now(); + ReportFormat format = ReportFormat.CSV; + byte[] mockReportData = "date,attendance\n2023-01-01,42".getBytes(); + + when(reportService.getAttendanceStatistics(startDate, endDate, format)).thenReturn(mockReportData); + + ResponseEntity response = userController.getAttendanceReport(startDate, endDate, format); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockReportData, response.getBody()); + + // Verify the correct content type is set for CSV + HttpHeaders headers = response.getHeaders(); + assertEquals(MediaType.parseMediaType("text/csv"), headers.getContentType()); + assertTrue(headers.getContentDisposition().toString().contains("attachment")); + assertTrue(headers.getContentDisposition().toString().contains("attendance_report.csv")); + + verify(reportService).getAttendanceStatistics(startDate, endDate, format); + } + + @Test + public void testBuildResponseWithJSON() { + // Use reflection to access the private method + ReportFormat format = ReportFormat.JSON; + byte[] content = "{\"data\": \"test\"}".getBytes(); + String filenameBase = "test_report"; + + // Create a method that directly invokes buildResponse using reflection + ResponseEntity response = null; + try { + java.lang.reflect.Method buildResponseMethod = UserController.class.getDeclaredMethod( + "buildResponse", byte[].class, ReportFormat.class, String.class); + buildResponseMethod.setAccessible(true); + response = (ResponseEntity) buildResponseMethod.invoke(userController, content, format, filenameBase); + } catch (Exception e) { + fail("Failed to invoke buildResponse method: " + e.getMessage()); + } + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(content, response.getBody()); + + // Verify JSON content type + HttpHeaders headers = response.getHeaders(); + assertEquals(MediaType.APPLICATION_JSON, headers.getContentType()); + assertTrue(headers.getContentDisposition().toString().contains("attachment")); + assertTrue(headers.getContentDisposition().toString().contains("test_report.json")); + } } \ No newline at end of file diff --git a/src/test/java/edu/eci/cvds/prometeo/model/enums/ReportFormatTest.java b/src/test/java/edu/eci/cvds/prometeo/model/enums/ReportFormatTest.java new file mode 100644 index 0000000..ac2ed56 --- /dev/null +++ b/src/test/java/edu/eci/cvds/prometeo/model/enums/ReportFormatTest.java @@ -0,0 +1,41 @@ +package edu.eci.cvds.prometeo.model.enums; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class ReportFormatTest { + + @Test + public void testEnumValues() { + // Test that the enum has the expected number of values + assertEquals(4, ReportFormat.values().length); + + // Test that each expected value exists + assertNotNull(ReportFormat.CSV); + assertNotNull(ReportFormat.PDF); + assertNotNull(ReportFormat.XLSX); + assertNotNull(ReportFormat.JSON); + } + + @Test + public void testValueOf() { + // Test that valueOf returns the correct enum value for each expected string + assertEquals(ReportFormat.CSV, ReportFormat.valueOf("CSV")); + assertEquals(ReportFormat.PDF, ReportFormat.valueOf("PDF")); + assertEquals(ReportFormat.XLSX, ReportFormat.valueOf("XLSX")); + assertEquals(ReportFormat.JSON, ReportFormat.valueOf("JSON")); + } + + + @Test + public void testEnumValuesContent() { + // Test that values() returns all expected values + ReportFormat[] formats = ReportFormat.values(); + + assertEquals(ReportFormat.CSV, formats[0]); + assertEquals(ReportFormat.PDF, formats[1]); + assertEquals(ReportFormat.XLSX, formats[2]); + assertEquals(ReportFormat.JSON, formats[3]); + } +} \ No newline at end of file diff --git a/src/test/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImplTest.java b/src/test/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImplTest.java new file mode 100644 index 0000000..a5abfe6 --- /dev/null +++ b/src/test/java/edu/eci/cvds/prometeo/service/impl/ReportServiceImplTest.java @@ -0,0 +1,409 @@ +package edu.eci.cvds.prometeo.service.impl; + +import edu.eci.cvds.prometeo.model.GymSession; +import edu.eci.cvds.prometeo.model.PhysicalProgress; +import edu.eci.cvds.prometeo.model.Weight; +import edu.eci.cvds.prometeo.model.enums.ReportFormat; +import edu.eci.cvds.prometeo.repository.GymSessionRepository; +import edu.eci.cvds.prometeo.repository.PhysicalProgressRepository; +import edu.eci.cvds.prometeo.service.report.ReportGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import java.io.IOException; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + + + + + + +@ExtendWith(MockitoExtension.class) +public class ReportServiceImplTest { + + @Mock + private PhysicalProgressRepository physicalProgressRepository; + + @Mock + private GymSessionRepository gymSessionRepository; + + @Mock + private ReportGenerator reportGeneratorMock; + + @InjectMocks + private ReportServiceImpl reportService; + + @BeforeEach + void setUp() { + // Set the mocked ReportGenerator to the reportService + ReflectionTestUtils.setField(reportService, "reportGenerator", reportGeneratorMock); + } + + @Test + void testGenerateUserProgressReport() throws IOException { + // Arrange + UUID userId = UUID.randomUUID(); + ReportFormat format = ReportFormat.PDF; + + PhysicalProgress progress1 = createTestProgress(userId, LocalDate.of(2023, 6, 1), 75.0, "Lose weight"); + PhysicalProgress progress2 = createTestProgress(userId, LocalDate.of(2023, 6, 15), 73.5, "Gain muscle"); + List progressList = Arrays.asList(progress1, progress2); + + when(physicalProgressRepository.findByUserIdOrderByRecordDateDesc(userId)).thenReturn(progressList); + + byte[] expectedReport = "PDF report content".getBytes(); + when(reportGeneratorMock.generatePDF(eq(progressList), anyString(), any())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.generateUserProgressReport(userId, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(physicalProgressRepository).findByUserIdOrderByRecordDateDesc(userId); + verify(reportGeneratorMock).generatePDF(eq(progressList), anyString(), any()); + } + + @Test + void testGenerateUserProgressReportWithXLSXFormat() throws IOException { + // Arrange + UUID userId = UUID.randomUUID(); + ReportFormat format = ReportFormat.XLSX; + + List progressList = Collections.emptyList(); + when(physicalProgressRepository.findByUserIdOrderByRecordDateDesc(userId)).thenReturn(progressList); + + byte[] expectedReport = "XLSX report content".getBytes(); + when(reportGeneratorMock.generateXLSX(eq(progressList), anyList(), any())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.generateUserProgressReport(userId, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(physicalProgressRepository).findByUserIdOrderByRecordDateDesc(userId); + verify(reportGeneratorMock).generateXLSX(eq(progressList), anyList(), any()); + } + + @Test + void testGenerateUserProgressReportThrowsException() throws IOException { + // Arrange + UUID userId = UUID.randomUUID(); + ReportFormat format = ReportFormat.PDF; + + List progressList = Collections.emptyList(); + when(physicalProgressRepository.findByUserIdOrderByRecordDateDesc(userId)).thenReturn(progressList); + + IOException ioException = new IOException("Test exception"); + when(reportGeneratorMock.generatePDF(eq(progressList), anyString(), any())).thenThrow(ioException); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> reportService.generateUserProgressReport(userId, format)); + + assertEquals("Error generando reporte en formato: " + format, exception.getMessage()); + assertEquals(ioException, exception.getCause()); + } + + @Test + void testGenerateGymUsageReport() throws IOException { + // Arrange + LocalDate startDate = LocalDate.of(2023, 6, 1); + LocalDate endDate = LocalDate.of(2023, 6, 30); + ReportFormat format = ReportFormat.PDF; + + GymSession session1 = createTestSession(LocalDate.of(2023, 6, 10), 20, 15, 60); + GymSession session2 = createTestSession(LocalDate.of(2023, 6, 20), 25, 20, 90); + List sessionList = Arrays.asList(session1, session2); + + when(gymSessionRepository.findBySessionDateBetween(startDate, endDate)).thenReturn(sessionList); + + byte[] expectedReport = "PDF report content".getBytes(); + when(reportGeneratorMock.generatePDF(eq(sessionList), anyString(), any())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.generateGymUsageReport(startDate, endDate, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(gymSessionRepository).findBySessionDateBetween(startDate, endDate); + verify(reportGeneratorMock).generatePDF(eq(sessionList), anyString(), any()); + } + + @Test + void testGenerateGymUsageReportWithJSONFormat() throws IOException { + // Arrange + LocalDate startDate = LocalDate.of(2023, 6, 1); + LocalDate endDate = LocalDate.of(2023, 6, 30); + ReportFormat format = ReportFormat.JSON; + + List sessionList = Collections.emptyList(); + when(gymSessionRepository.findBySessionDateBetween(startDate, endDate)).thenReturn(sessionList); + + byte[] expectedReport = "JSON report content".getBytes(); + when(reportGeneratorMock.generateJSON(anyList())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.generateGymUsageReport(startDate, endDate, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(gymSessionRepository).findBySessionDateBetween(startDate, endDate); + verify(reportGeneratorMock).generateJSON(anyList()); + } + + @Test + void testGetAttendanceStatistics() throws IOException { + // Arrange + LocalDate startDate = LocalDate.of(2023, 6, 1); + LocalDate endDate = LocalDate.of(2023, 6, 30); + ReportFormat format = ReportFormat.PDF; + + GymSession session1 = createTestSession(LocalDate.of(2023, 6, 10), 20, 15, 60); + GymSession session2 = createTestSession(LocalDate.of(2023, 6, 20), 25, 20, 90); + List sessionList = Arrays.asList(session1, session2); + + when(gymSessionRepository.findBySessionDateBetween(startDate, endDate)).thenReturn(sessionList); + + byte[] expectedReport = "PDF report content".getBytes(); + when(reportGeneratorMock.generatePDF(anyList(), anyString(), any())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.getAttendanceStatistics(startDate, endDate, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(gymSessionRepository).findBySessionDateBetween(startDate, endDate); + verify(reportGeneratorMock).generatePDF(anyList(), anyString(), any()); + } + + @Test + void testGetAttendanceStatisticsWithCSVFormat() throws IOException { + // Arrange + LocalDate startDate = LocalDate.of(2023, 6, 1); + LocalDate endDate = LocalDate.of(2023, 6, 30); + ReportFormat format = ReportFormat.CSV; + + List sessionList = Collections.emptyList(); + when(gymSessionRepository.findBySessionDateBetween(startDate, endDate)).thenReturn(sessionList); + + byte[] expectedReport = "CSV report content".getBytes(); + when(reportGeneratorMock.generateCSV(anyList(), anyList(), any())).thenReturn(expectedReport); + + // Act + byte[] result = reportService.getAttendanceStatistics(startDate, endDate, format); + + // Assert + assertArrayEquals(expectedReport, result); + verify(gymSessionRepository).findBySessionDateBetween(startDate, endDate); + verify(reportGeneratorMock).generateCSV(anyList(), anyList(), any()); + } + + @Test + void testGetAttendanceStatisticsThrowsException() throws IOException { + // Arrange + LocalDate startDate = LocalDate.of(2023, 6, 1); + LocalDate endDate = LocalDate.of(2023, 6, 30); + ReportFormat format = ReportFormat.PDF; + + List sessionList = Collections.emptyList(); + when(gymSessionRepository.findBySessionDateBetween(startDate, endDate)).thenReturn(sessionList); + + IOException ioException = new IOException("Test exception"); + when(reportGeneratorMock.generatePDF(anyList(), anyString(), any())).thenThrow(ioException); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> reportService.getAttendanceStatistics(startDate, endDate, format)); + + assertEquals("Error generando reporte en formato: " + format, exception.getMessage()); + assertEquals(ioException, exception.getCause()); + } @Test + void testMapLine() throws Exception { + // Arrange + GymSession session = createTestSession(LocalDate.of(2023, 6, 10), 20, 15, 60); + String expectedLine = String.format( + "Fecha: %s | Capacidad Total: %d | Reservas Totales: %d | Tasa de UtilizaciΓ³n: %.2f%% | UtilizaciΓ³n Promedio: %.2f%% | DuraciΓ³n Promedio: %d minutos", + session.getSessionDate(), session.getCapacity(), session.getReservedSpots(), + session.getReservedSpots() * 100.0 / session.getCapacity(), + session.getReservedSpots() * 100.0 / session.getCapacity(), + session.getDuration().toMinutes() + ); + + // Invoke private method through reflection + java.lang.reflect.Method mapLineMethod = ReportServiceImpl.class.getDeclaredMethod( + "mapLine", GymSession.class); + mapLineMethod.setAccessible(true); + + // Act + String result = (String) mapLineMethod.invoke(reportService, session); + + // Assert + assertEquals(expectedLine, result); + } + + @Test + void testUserProgressLineMapper() throws Exception { + // Arrange + PhysicalProgress progress = createTestProgress(UUID.randomUUID(), + LocalDate.of(2023, 6, 1), 75.0, "Lose weight"); + + String expectedLine = "Fecha: " + progress.getRecordDate() + + " | Peso: " + progress.getWeight().getValue() + "kg" + + " | Meta: " + progress.getPhysicalGoal(); + + // Get the lineMapper function directly from the implementation + // This tests the lambda$generateUserProgressReport$0 method + java.lang.reflect.Method generateUserProgressReportMethod = ReportServiceImpl.class.getDeclaredMethod( + "generateUserProgressReport", UUID.class, ReportFormat.class); + generateUserProgressReportMethod.setAccessible(true); + + // We need to extract the function from the implementation + // Since we can't directly access lambdas, we'll test the behavior by using the ReportGenerator's call pattern + + // Mock the generatePDF to capture the lineMapper function + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + java.util.function.Function lineMapper = + (java.util.function.Function) invocation.getArgument(2); + + // Apply the lineMapper to our test progress + String result = lineMapper.apply(progress); + + // Assert within the mock + assertEquals(expectedLine, result); + + return "test".getBytes(); + }).when(reportGeneratorMock).generatePDF(anyList(), anyString(), any()); + + // Act - trigger the use of the lineMapper + reportService.generateUserProgressReport(UUID.randomUUID(), ReportFormat.PDF); + } + + @Test + void testUserProgressRowMapper() throws Exception { + // Arrange + PhysicalProgress progress = createTestProgress(UUID.randomUUID(), + LocalDate.of(2023, 6, 1), 75.0, "Lose weight"); + + List expectedRow = List.of( + progress.getRecordDate().toString(), + String.valueOf(progress.getWeight().getValue()), + progress.getPhysicalGoal() + ); + + // Mock the generateXLSX to capture the rowMapper function + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + java.util.function.Function> rowMapper = + (java.util.function.Function>) invocation.getArgument(2); + + // Apply the rowMapper to our test progress + List result = rowMapper.apply(progress); + + // Assert within the mock + assertEquals(expectedRow, result); + + return "test".getBytes(); + }).when(reportGeneratorMock).generateXLSX(anyList(), anyList(), any()); + + // Act - trigger the use of the rowMapper + reportService.generateUserProgressReport(UUID.randomUUID(), ReportFormat.XLSX); + } + + @Test + void testAttendanceStatisticsLineMapper() throws IOException { + // Arrange + LocalDate date = LocalDate.of(2023, 6, 1); + Integer attendanceCount = 42; + Map.Entry entry = Map.entry(date, attendanceCount); + + String expectedLine = "Fecha: " + date + " | Asistencias: " + attendanceCount; + + // Mock the generatePDF to capture the lineMapper function + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + java.util.function.Function, String> lineMapper = + (java.util.function.Function, String>) invocation.getArgument(2); + + // Apply the lineMapper to our test entry + String result = lineMapper.apply(entry); + + // Assert within the mock + assertEquals(expectedLine, result); + + return "test".getBytes(); + }).when(reportGeneratorMock).generatePDF(anyList(), anyString(), any()); + + // Act - trigger the use of the lineMapper + reportService.getAttendanceStatistics(LocalDate.now(), LocalDate.now(), ReportFormat.PDF); + } + + @Test + void testAttendanceStatisticsRowMapper() throws IOException { + // Arrange + LocalDate date = LocalDate.of(2023, 6, 1); + Integer attendanceCount = 42; + Map.Entry entry = Map.entry(date, attendanceCount); + + List expectedRow = List.of( + date.toString(), + String.valueOf(attendanceCount) + ); + + // Mock the generateXLSX to capture the rowMapper function + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + java.util.function.Function, List> rowMapper = + (java.util.function.Function, List>) invocation.getArgument(2); + + // Apply the rowMapper to our test entry + List result = rowMapper.apply(entry); + + // Assert within the mock + assertEquals(expectedRow, result); + + return "test".getBytes(); + }).when(reportGeneratorMock).generateXLSX(anyList(), anyList(), any()); + + // Act - trigger the use of the rowMapper + reportService.getAttendanceStatistics(LocalDate.now(), LocalDate.now(), ReportFormat.XLSX); + } + + // Helper methods to create test data + private PhysicalProgress createTestProgress(UUID userId, LocalDate date, double weightValue, String goal) { + PhysicalProgress progress = new PhysicalProgress(); + progress.setUserId(userId); + progress.setRecordDate(date); + + Weight weight = new Weight(); + weight.setValue(weightValue); + progress.setWeight(weight); + progress.setPhysicalGoal(goal); + + return progress; + } + private GymSession createTestSession(LocalDate date, int capacity, int reservedSpots, int durationMinutes) { + GymSession session = new GymSession(); + session.setSessionDate(date); + session.setCapacity(capacity); + session.setReservedSpots(reservedSpots); + // Set start and end times so that getDuration() works properly + session.setStartTime(LocalTime.of(10, 0)); // 10:00 AM + session.setEndTime(LocalTime.of(10, 0).plusMinutes(durationMinutes)); // End time based on duration + return session; + } +} \ No newline at end of file diff --git a/src/test/java/edu/eci/cvds/prometeo/service/report/ReportGeneratorTest.java b/src/test/java/edu/eci/cvds/prometeo/service/report/ReportGeneratorTest.java new file mode 100644 index 0000000..2f1552c --- /dev/null +++ b/src/test/java/edu/eci/cvds/prometeo/service/report/ReportGeneratorTest.java @@ -0,0 +1,198 @@ +package edu.eci.cvds.prometeo.service.report; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import static org.junit.jupiter.api.Assertions.*; + + + + + + +public class ReportGeneratorTest { + + private ReportGenerator reportGenerator; + private List testDataList; + private List headers; + private Function> rowMapper; + private Function lineMapper; + + @BeforeEach + void setUp() { + reportGenerator = new ReportGenerator(); + testDataList = Arrays.asList( + new TestData(1, "Item 1", 100.0), + new TestData(2, "Item 2", 200.0), + new TestData(3, "Item 3", 300.0) + ); + headers = Arrays.asList("ID", "Name", "Value"); + rowMapper = data -> Arrays.asList( + String.valueOf(data.getId()), + data.getName(), + String.valueOf(data.getValue()) + ); + lineMapper = data -> String.format("ID: %d, Name: %s, Value: %.2f", + data.getId(), data.getName(), data.getValue()); + } + + @Test + void testGenerateJSON() throws IOException { + // Given testDataList from setUp + + // When + byte[] result = reportGenerator.generateJSON(testDataList); + + // Then + assertNotNull(result); + assertTrue(result.length > 0); + + // Verify content by deserializing + ObjectMapper mapper = new ObjectMapper(); + List deserializedData = mapper.readValue(result, new TypeReference>() {}); + assertEquals(testDataList.size(), deserializedData.size()); + for (int i = 0; i < testDataList.size(); i++) { + assertEquals(testDataList.get(i).getId(), deserializedData.get(i).getId()); + assertEquals(testDataList.get(i).getName(), deserializedData.get(i).getName()); + assertEquals(testDataList.get(i).getValue(), deserializedData.get(i).getValue(), 0.001); + } + } + + @Test + void testGenerateCSV() { + // Given testDataList, headers, and rowMapper from setUp + + // When + byte[] result = reportGenerator.generateCSV(testDataList, headers, rowMapper); + + // Then + assertNotNull(result); + String csvContent = new String(result, StandardCharsets.UTF_8); + + // Verify header row + assertTrue(csvContent.startsWith("ID,Name,Value")); + + // Verify data rows + assertTrue(csvContent.contains("1,Item 1,100.0")); + assertTrue(csvContent.contains("2,Item 2,200.0")); + assertTrue(csvContent.contains("3,Item 3,300.0")); + } + + @Test + void testGenerateXLSX() throws IOException { + // Given testDataList, headers, and rowMapper from setUp + + // When + byte[] result = reportGenerator.generateXLSX(testDataList, headers, rowMapper); + + // Then + assertNotNull(result); + assertTrue(result.length > 0); + + // Verify Excel content + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(result))) { + Sheet sheet = workbook.getSheetAt(0); + assertEquals("Reporte", sheet.getSheetName()); + + // Check header row + Row headerRow = sheet.getRow(0); + assertEquals("ID", headerRow.getCell(0).getStringCellValue()); + assertEquals("Name", headerRow.getCell(1).getStringCellValue()); + assertEquals("Value", headerRow.getCell(2).getStringCellValue()); + + // Check data rows + Row firstRow = sheet.getRow(1); + assertEquals("1", firstRow.getCell(0).getStringCellValue()); + assertEquals("Item 1", firstRow.getCell(1).getStringCellValue()); + assertEquals("100.0", firstRow.getCell(2).getStringCellValue()); + } + } + + @Test + void testGeneratePDF() throws IOException { + // Given testDataList and lineMapper from setUp + + // When + byte[] result = reportGenerator.generatePDF(testDataList, "Test Report", lineMapper); + + // Then + assertNotNull(result); + assertTrue(result.length > 0); + + // Verify PDF is valid by loading it + try (PDDocument document = PDDocument.load(new ByteArrayInputStream(result))) { + assertNotNull(document); + assertTrue(document.getNumberOfPages() > 0); + } + } + + @Test + void testEmptyList() throws IOException { + // Given + List emptyList = Collections.emptyList(); + + // When + byte[] jsonResult = reportGenerator.generateJSON(emptyList); + byte[] csvResult = reportGenerator.generateCSV(emptyList, headers, rowMapper); + byte[] xlsxResult = reportGenerator.generateXLSX(emptyList, headers, rowMapper); + byte[] pdfResult = reportGenerator.generatePDF(emptyList, "Empty Report", lineMapper); + + // Then + assertEquals("[]", new String(jsonResult, StandardCharsets.UTF_8)); + assertTrue(new String(csvResult, StandardCharsets.UTF_8).contains("ID,Name,Value")); + assertTrue(xlsxResult.length > 0); + assertTrue(pdfResult.length > 0); + } + + // Simple test data class + static class TestData { + private int id; + private String name; + private double value; + + public TestData() { + // Default constructor for Jackson + } + + public TestData(int id, String name, double value) { + this.id = id; + this.name = name; + this.value = value; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public double getValue() { + return value; + } + + public void setValue(double value) { + this.value = value; + } + } +} \ No newline at end of file