From 0ab17d6fe8fb14eaafa5d300e54a4294f0aa2af9 Mon Sep 17 00:00:00 2001 From: AsierCL Date: Thu, 13 Aug 2026 18:19:42 +0200 Subject: [PATCH] =?UTF-8?q?feat/Estad=C3=ADsticas=20b=C3=A1sicas=20de=20ap?= =?UTF-8?q?licaci=C3=B3n=20(usuarios,=20archivos)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/gradox2/init/BackfillRunner.java | 51 +++++++ .../gradox2/persistence/entities/File.java | 14 ++ .../persistence/entities/TempFile.java | 3 + .../repository/FileRepository.java | 32 ++++ .../controller/StatsController.java | 32 ++++ .../dto/stats/PlatformStatsResponse.java | 69 +++++++++ .../gradox2/security/SecurityConfig.java | 1 + .../FileProposalServiceImpl.java | 1 + .../implementation/FileServiceImpl.java | 4 + .../implementation/S3StorageService.java | 18 +++ .../implementation/StatsServiceImpl.java | 48 ++++++ .../implementation/VoteServiceImpl.java | 1 + .../service/interfaces/IStatsService.java | 8 + .../resources/db/migration/V4__file_stats.sql | 7 + .../example/gradox2/StatsIntegrationTest.java | 141 ++++++++++++++++++ 15 files changed, 430 insertions(+) create mode 100644 gradox2/src/main/java/com/example/gradox2/init/BackfillRunner.java create mode 100644 gradox2/src/main/java/com/example/gradox2/presentation/controller/StatsController.java create mode 100644 gradox2/src/main/java/com/example/gradox2/presentation/dto/stats/PlatformStatsResponse.java create mode 100644 gradox2/src/main/java/com/example/gradox2/service/implementation/StatsServiceImpl.java create mode 100644 gradox2/src/main/java/com/example/gradox2/service/interfaces/IStatsService.java create mode 100644 gradox2/src/main/resources/db/migration/V4__file_stats.sql create mode 100644 gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java diff --git a/gradox2/src/main/java/com/example/gradox2/init/BackfillRunner.java b/gradox2/src/main/java/com/example/gradox2/init/BackfillRunner.java new file mode 100644 index 0000000..03ed052 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/init/BackfillRunner.java @@ -0,0 +1,51 @@ +package com.example.gradox2.init; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gradox2.persistence.entities.File; +import com.example.gradox2.persistence.repository.FileRepository; +import com.example.gradox2.service.implementation.S3StorageService; + +import lombok.RequiredArgsConstructor; + +/** + * One-off: rellena el tamaño (sizeBytes) de archivos publicados antes de que + * la columna existiera, consultando S3 (headObject). Es idempotente: solo + * procesa registros con tamaño nulo y tolera objetos inaccesibles. + */ +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +@RequiredArgsConstructor +public class BackfillRunner implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(BackfillRunner.class); + + private final FileRepository fileRepository; + private final S3StorageService s3StorageService; + + @Override + @Transactional + public void run(String... args) { + var files = fileRepository.findBySizeBytesIsNull(); + if (files.isEmpty()) { + return; + } + + int updated = 0; + for (File file : files) { + Long size = s3StorageService.sizeOf(file.getObjectKey()); + if (size != null) { + file.setSizeBytes(size); + updated++; + } + } + fileRepository.saveAll(files); + log.info("Backfill de tamaños de archivos completado: {} actualizados de {} pendientes.", updated, files.size()); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/entities/File.java b/gradox2/src/main/java/com/example/gradox2/persistence/entities/File.java index d5cd6d6..e9b3e8e 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/entities/File.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/entities/File.java @@ -33,6 +33,13 @@ public class File { private String objectKey; private String fileHash; + @Column(name = "size_bytes") + private Long sizeBytes; + + @Builder.Default + @Column(name = "download_count", nullable = false) + private Long downloadCount = 0L; + @Builder.Default private Instant uploadAt = Instant.now(); @@ -70,6 +77,13 @@ public void removeScore(Score score) { this.score = calculateTotalScore(); } + public void incrementDownloadCount() { + if (this.downloadCount == null) { + this.downloadCount = 0L; + } + this.downloadCount++; + } + // El score total ahora se puede calcular dinámicamente public Double calculateTotalScore() { return scores.stream() diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/entities/TempFile.java b/gradox2/src/main/java/com/example/gradox2/persistence/entities/TempFile.java index 1295fc3..cbbf024 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/entities/TempFile.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/entities/TempFile.java @@ -30,6 +30,9 @@ public class TempFile { private String objectKey; private String fileHash; + @Column(name = "size_bytes") + private Long sizeBytes; + @Builder.Default private Instant uploadAt = Instant.now(); diff --git a/gradox2/src/main/java/com/example/gradox2/persistence/repository/FileRepository.java b/gradox2/src/main/java/com/example/gradox2/persistence/repository/FileRepository.java index 0409028..75ac3da 100644 --- a/gradox2/src/main/java/com/example/gradox2/persistence/repository/FileRepository.java +++ b/gradox2/src/main/java/com/example/gradox2/persistence/repository/FileRepository.java @@ -3,6 +3,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import com.example.gradox2.persistence.entities.File; import com.example.gradox2.persistence.entities.enums.FileType; @@ -14,4 +15,35 @@ public interface FileRepository extends JpaRepository { List findBySubjectId(Long subjectId); List findByUploaderId(Long uploaderId); List findByUploaderIdAndSubjectId(Long uploaderId, Long subjectId); + List findBySizeBytesIsNull(); + + //Stats + @Query("SELECT COALESCE(SUM(f.sizeBytes), 0) FROM File f") + Long sumSizeBytes(); + + @Query("SELECT COALESCE(SUM(f.downloadCount), 0) FROM File f") + Long sumDownloadCount(); + + @Query("SELECT f.type AS type, COUNT(f) AS count FROM File f GROUP BY f.type ORDER BY COUNT(f) DESC") + List countByType(); + + @Query("SELECT s.code AS code, s.name AS name, c.name AS courseName, COUNT(f) AS count " + + "FROM Subject s " + + "LEFT JOIN s.resources f " + + "LEFT JOIN s.course c " + + "GROUP BY s.id, s.code, s.name, c.name " + + "ORDER BY COUNT(f) DESC") + List countBySubject(); + + interface FileTypeCount { + FileType getType(); + long getCount(); + } + + interface SubjectFileCount { + String getCode(); + String getName(); + String getCourseName(); + long getCount(); + } } diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/controller/StatsController.java b/gradox2/src/main/java/com/example/gradox2/presentation/controller/StatsController.java new file mode 100644 index 0000000..9e4a4f0 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/presentation/controller/StatsController.java @@ -0,0 +1,32 @@ +package com.example.gradox2.presentation.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse; +import com.example.gradox2.service.interfaces.IStatsService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/stats") +@Tag(name = "Estadísticas", description = "Métricas colectivas de la plataforma para la comunidad") +public class StatsController { + + private final IStatsService statsService; + + public StatsController(IStatsService statsService) { + this.statsService = statsService; + } + + @GetMapping + @Operation(summary = "Estadísticas de la plataforma", description = "Métricas globales: total de archivos, usuarios, almacenamiento y descargas, con desglose por tipo y asignatura") + @ApiResponse(responseCode = "200", description = "Estadísticas devueltas") + public ResponseEntity getPlatformStats() { + return ResponseEntity.ok(statsService.getPlatformStats()); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/dto/stats/PlatformStatsResponse.java b/gradox2/src/main/java/com/example/gradox2/presentation/dto/stats/PlatformStatsResponse.java new file mode 100644 index 0000000..2a38023 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/presentation/dto/stats/PlatformStatsResponse.java @@ -0,0 +1,69 @@ +package com.example.gradox2.presentation.dto.stats; + +import java.util.List; + +import com.example.gradox2.persistence.entities.enums.FileType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Métricas colectivas de la plataforma para la comunidad") +public class PlatformStatsResponse { + + @Schema(description = "Número total de archivos publicados", example = "124") + private long totalFiles; + + @Schema(description = "Número total de usuarios registrados", example = "532") + private long totalUsers; + + @Schema(description = "Tamaño total almacenado en bytes", example = "1536000000") + private long totalStorageBytes; + + @Schema(description = "Número total de descargas realizadas", example = "8421") + private long totalDownloads; + + @Schema(description = "Desglose de archivos por tipo") + private List byType; + + @Schema(description = "Desglose de archivos por asignatura") + private List bySubject; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "Archivos por tipo") + public static class TypeStat { + @Schema(description = "Tipo de archivo", example = "APUNTES") + private FileType type; + + @Schema(description = "Número de archivos de este tipo", example = "80") + private long count; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "Archivos por asignatura") + public static class SubjectStat { + @Schema(description = "Código de la asignatura", example = "MAT1") + private String code; + + @Schema(description = "Nombre de la asignatura", example = "Matemáticas I") + private String name; + + @Schema(description = "Nombre del curso al que pertenece", example = "Primer curso") + private String courseName; + + @Schema(description = "Número de archivos de esta asignatura", example = "12") + private long count; + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java b/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java index 4a9c8d7..da19a98 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java +++ b/gradox2/src/main/java/com/example/gradox2/security/SecurityConfig.java @@ -60,6 +60,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/files/**").permitAll() .requestMatchers(HttpMethod.GET, "/subjects/**").permitAll() .requestMatchers(HttpMethod.GET, "/badges/**").permitAll() + .requestMatchers(HttpMethod.GET, "/stats/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/health**", "/actuator/health/**", "/actuator/info").permitAll() .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/FileProposalServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/FileProposalServiceImpl.java index 44d4248..465ce58 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/FileProposalServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/FileProposalServiceImpl.java @@ -90,6 +90,7 @@ public FileProposalResponse uploadFileProposal(UploadFileProposalRequest dto) { .type(dto.getType()) .objectKey(objectKey) .fileHash(generateFileHash(fileData)) // Hash seguro + .sizeBytes((long) fileData.length) .subject(subject) .uploader(uploader) .visibilityLevel(dto.getVisibilityLevel() != null ? dto.getVisibilityLevel() : FileVisibility.PUBLIC) diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/FileServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/FileServiceImpl.java index aa7419c..d2792f3 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/FileServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/FileServiceImpl.java @@ -59,6 +59,7 @@ public FileServiceImpl(FileRepository fileRepository, this.fileUrlSigner = fileUrlSigner; } + @Transactional public FileDownloadResponse downloadFile(Long id) { File file = fileRepository.findById(id) .orElseThrow(() -> new NotFoundException("File not found")); @@ -69,6 +70,9 @@ public FileDownloadResponse downloadFile(Long id) { throw new NotFoundException("File not found"); } + file.incrementDownloadCount(); + fileRepository.save(file); + String url = fileUrlSigner.presignedGetUrl(file.getObjectKey(), ContentDisposition.attachmentOf(file.getTitle())); return FileDownloadResponse.builder().url(url).build(); diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/S3StorageService.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/S3StorageService.java index 25cdb1c..695ac69 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/S3StorageService.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/S3StorageService.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.model.CreateBucketRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.NoSuchBucketException; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -60,4 +61,21 @@ public void delete(String key) { .key(key) .build()); } + + /** + * Tamaño en bytes del objeto, o {@code null} si no se puede determinar. + */ + public Long sizeOf(String key) { + if (key == null || key.isBlank()) { + return null; + } + try { + return s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build()).contentLength(); + } catch (S3Exception e) { + return null; + } + } } diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/StatsServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/StatsServiceImpl.java new file mode 100644 index 0000000..84bd461 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/StatsServiceImpl.java @@ -0,0 +1,48 @@ +package com.example.gradox2.service.implementation; + +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gradox2.persistence.repository.FileRepository; +import com.example.gradox2.persistence.repository.UserRepository; +import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse; +import com.example.gradox2.service.interfaces.IStatsService; + +@Service +public class StatsServiceImpl implements IStatsService { + + private final FileRepository fileRepository; + private final UserRepository userRepository; + + public StatsServiceImpl(FileRepository fileRepository, UserRepository userRepository) { + this.fileRepository = fileRepository; + this.userRepository = userRepository; + } + + @Override + @Transactional(readOnly = true) + public PlatformStatsResponse getPlatformStats() { + return PlatformStatsResponse.builder() + .totalFiles(fileRepository.count()) + .totalUsers(userRepository.count()) + .totalStorageBytes(fileRepository.sumSizeBytes()) + .totalDownloads(fileRepository.sumDownloadCount()) + .byType(fileRepository.countByType().stream() + .map(stat -> PlatformStatsResponse.TypeStat.builder() + .type(stat.getType()) + .count(stat.getCount()) + .build()) + .collect(Collectors.toList())) + .bySubject(fileRepository.countBySubject().stream() + .map(stat -> PlatformStatsResponse.SubjectStat.builder() + .code(stat.getCode()) + .name(stat.getName()) + .courseName(stat.getCourseName()) + .count(stat.getCount()) + .build()) + .collect(Collectors.toList())) + .build(); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/VoteServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/VoteServiceImpl.java index 634d0cd..a798627 100644 --- a/gradox2/src/main/java/com/example/gradox2/service/implementation/VoteServiceImpl.java +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/VoteServiceImpl.java @@ -244,6 +244,7 @@ private void applyFileProposal(FileProposal fileProposal) { .type(tempFile.getType()) .objectKey(tempFile.getObjectKey()) .fileHash(tempFile.getFileHash()) + .sizeBytes(tempFile.getSizeBytes()) .uploader(tempFile.getUploader()) .subject(tempFile.getSubject()) .visibilityLevel(tempFile.getVisibilityLevel()) diff --git a/gradox2/src/main/java/com/example/gradox2/service/interfaces/IStatsService.java b/gradox2/src/main/java/com/example/gradox2/service/interfaces/IStatsService.java new file mode 100644 index 0000000..110093c --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/interfaces/IStatsService.java @@ -0,0 +1,8 @@ +package com.example.gradox2.service.interfaces; + +import com.example.gradox2.presentation.dto.stats.PlatformStatsResponse; + +public interface IStatsService { + + PlatformStatsResponse getPlatformStats(); +} \ No newline at end of file diff --git a/gradox2/src/main/resources/db/migration/V4__file_stats.sql b/gradox2/src/main/resources/db/migration/V4__file_stats.sql new file mode 100644 index 0000000..8b780fc --- /dev/null +++ b/gradox2/src/main/resources/db/migration/V4__file_stats.sql @@ -0,0 +1,7 @@ +-- V4: métricas comunitarias de archivos. +-- Guardamos el tamaño (bytes) y el contador de descargas para las estadísticas /stats. + +alter table files add column size_bytes bigint; +alter table files add column download_count bigint not null default 0; + +alter table temp_files add column size_bytes bigint; \ No newline at end of file diff --git a/gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java new file mode 100644 index 0000000..d213799 --- /dev/null +++ b/gradox2/src/test/java/com/example/gradox2/StatsIntegrationTest.java @@ -0,0 +1,141 @@ +package com.example.gradox2; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.example.gradox2.persistence.entities.Course; +import com.example.gradox2.persistence.entities.File; +import com.example.gradox2.persistence.entities.Subject; +import com.example.gradox2.persistence.entities.User; +import com.example.gradox2.persistence.entities.enums.FileType; +import com.example.gradox2.persistence.entities.enums.FileVisibility; +import com.example.gradox2.persistence.entities.enums.UserRole; +import com.example.gradox2.persistence.repository.CourseRepository; +import com.example.gradox2.persistence.repository.FileRepository; +import com.example.gradox2.persistence.repository.SubjectRepository; +import com.example.gradox2.persistence.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class StatsIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FileRepository fileRepository; + + @Autowired + private SubjectRepository subjectRepository; + + @Autowired + private CourseRepository courseRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + void statsEndpointIsPublicAndReturnsExpectedShape() throws Exception { + mockMvc.perform(get("/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalFiles").isNumber()) + .andExpect(jsonPath("$.totalUsers").isNumber()) + .andExpect(jsonPath("$.totalStorageBytes").isNumber()) + .andExpect(jsonPath("$.totalDownloads").isNumber()) + .andExpect(jsonPath("$.byType").isArray()) + .andExpect(jsonPath("$.bySubject").isArray()); + } + + @Test + void downloadsAndUploadsAreReflectedInStats() throws Exception { + User uploader = createEnabledUser("statowner", "statowner@rai.usc.es", "SecurePass1!", UserRole.USER); + + JsonNode before = stats(); + + Subject subject = createSubject(); + fileRepository.save(File.builder() + .title("apuntes-stats.pdf") + .description("Descripcion") + .type(FileType.APUNTES) + .objectKey("key-stats" + System.nanoTime()) + .fileHash("hash-stats") + .sizeBytes(1024L) + .uploader(uploader) + .subject(subject) + .visibilityLevel(FileVisibility.PUBLIC) + .build()); + + Long fileId = fileRepository.findAll().stream() + .filter(f -> f.getObjectKey().startsWith("key-stats")) + .findFirst().orElseThrow().getId(); + + mockMvc.perform(get("/files/{id}/download", fileId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").exists()); + + JsonNode after = stats(); + + org.junit.jupiter.api.Assertions.assertEquals( + before.get("totalDownloads").asLong() + 1, + after.get("totalDownloads").asLong()); + org.junit.jupiter.api.Assertions.assertEquals( + before.get("totalFiles").asLong() + 1, + after.get("totalFiles").asLong()); + org.junit.jupiter.api.Assertions.assertEquals( + before.get("totalUsers").asLong(), + after.get("totalUsers").asLong()); + org.junit.jupiter.api.Assertions.assertEquals( + before.get("totalStorageBytes").asLong() + 1024L, + after.get("totalStorageBytes").asLong()); + } + + private JsonNode stats() throws Exception { + MvcResult result = mockMvc.perform(get("/stats")) + .andExpect(status().isOk()) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()); + } + + private User createEnabledUser(String username, String email, String password, UserRole role) { + User user = User.builder() + .username(username) + .email(email) + .passwordHash(passwordEncoder.encode(password)) + .enabled(true) + .role(role) + .build(); + return userRepository.save(user); + } + + private Subject createSubject() { + Course course = courseRepository.save(Course.builder() + .code("STC" + System.nanoTime()) + .name("Curso Stats") + .build()); + + return subjectRepository.save(Subject.builder() + .code("STS" + System.nanoTime()) + .name("Asignatura Stats") + .course(course) + .build()); + } +} \ No newline at end of file