diff --git a/.github/workflows/ci-develop.yml b/.github/workflows/ci-develop.yml index 047c9a7..230fbcc 100644 --- a/.github/workflows/ci-develop.yml +++ b/.github/workflows/ci-develop.yml @@ -49,40 +49,6 @@ jobs: npm run test -- --watch=false --code-coverage --browsers=ChromeHeadless working-directory: frontend - e2e: - name: Cypress E2E - runs-on: ubuntu-latest - needs: [backend, frontend] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Environment (Java & Node) - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install Frontend - run: npm ci - working-directory: frontend - - - name: Run Backend + Frontend + Cypress - run: | - cd frontend - npx start-server-and-test \ - "cd ../backend && mvn spring-boot:run -Dspring-boot.run.arguments='--spring.profiles.active=test --spring.datasource.url=jdbc:h2:mem:gameswap_db;DB_CLOSE_DELAY=-1;MODE=PostgreSQL --spring.datasource.driverClassName=org.h2.Driver --spring.datasource.username=sa --spring.datasource.password= --spring.jpa.database-platform=org.hibernate.dialect.H2Dialect --spring.flyway.enabled=true --spring.security.user.name=admin --spring.security.user.password=admin'" \ - "http://localhost:8080/api/usuarios" \ - "npm start" \ - "http://localhost:4200" \ - "npx cypress run" - sonar: name: Unified Sonar Analysis runs-on: ubuntu-latest diff --git a/backend/pom.xml b/backend/pom.xml index 99decbe..5605717 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -103,6 +103,22 @@ runtime + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + + diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/controller/CarritoController.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/controller/CarritoController.java index 885eb78..9a12354 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/controller/CarritoController.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/controller/CarritoController.java @@ -26,9 +26,7 @@ public CarritoResponseDTO findByUser(@PathVariable Long id) { @PostMapping("/add/{idPostVenta}") public CarritoResponseDTO addProduct(@PathVariable Long idPostVenta) { - - Long idUsuario = 1L; - return service.addProduct(idPostVenta, idUsuario); + return service.addProduct(idPostVenta); } @PostMapping("/remove") diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/request/UsuarioRequestDTO.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/request/UsuarioRequestDTO.java index 5d4d3f8..9c039e3 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/request/UsuarioRequestDTO.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/request/UsuarioRequestDTO.java @@ -1,5 +1,6 @@ package com.tfg.angel.gameswap.backend.business.dto.request; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import lombok.*; import java.time.LocalDate; @@ -15,4 +16,5 @@ public class UsuarioRequestDTO { private String nUsuario; private LocalDate fechaNacimiento; private String correo; + private Rol rol; } \ No newline at end of file diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/response/UsuarioResponseDTO.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/response/UsuarioResponseDTO.java index d61a304..8a0aca1 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/response/UsuarioResponseDTO.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/dto/response/UsuarioResponseDTO.java @@ -1,5 +1,6 @@ package com.tfg.angel.gameswap.backend.business.dto.response; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import lombok.*; import java.time.LocalDate; @@ -18,4 +19,5 @@ public class UsuarioResponseDTO { private Double saldo; private String correo; private Double estrellas; + private Rol rol; } diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/mapper/UsuarioMapper.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/mapper/UsuarioMapper.java index 4b7c0de..0314b80 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/mapper/UsuarioMapper.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/mapper/UsuarioMapper.java @@ -15,6 +15,7 @@ public static Usuario toEntity(UsuarioRequestDTO dto) { .correo(dto.getCorreo()) .saldo(0.0) .estrellas(0.0) + .rol(dto.getRol()) .build(); } @@ -27,6 +28,7 @@ public static UsuarioResponseDTO toDTO(Usuario usuario) { .saldo(usuario.getSaldo()) .correo(usuario.getCorreo()) .estrellas(usuario.getEstrellas()) + .rol(usuario.getRol()) .build(); } } diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/Usuario.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/Usuario.java index f389197..e405a02 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/Usuario.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/Usuario.java @@ -1,5 +1,6 @@ package com.tfg.angel.gameswap.backend.business.model; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import jakarta.persistence.*; import lombok.*; @@ -32,6 +33,12 @@ public class Usuario { private Double estrellas = 0.0; + @Enumerated(EnumType.STRING) + @Column(name = "rol") + private Rol rol; + + private String password; + // Relaciones @OneToMany(mappedBy = "comprador", fetch = FetchType.LAZY) diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/enums/Rol.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/enums/Rol.java new file mode 100644 index 0000000..01ecf16 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/model/enums/Rol.java @@ -0,0 +1,6 @@ +package com.tfg.angel.gameswap.backend.business.model.enums; + +public enum Rol { + ADMIN, + CLIENTE +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/CarritoService.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/CarritoService.java index ede6a39..b7299ef 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/CarritoService.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/CarritoService.java @@ -9,7 +9,7 @@ public interface CarritoService { CarritoResponseDTO findByUser(Long idUsuario); - CarritoResponseDTO addProduct(Long idPostVenta, Long idUsuario); + CarritoResponseDTO addProduct(Long idPostVenta); CarritoResponseDTO removeProduct(Long idProductoCarrito); diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/impl/CarritoServiceImpl.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/impl/CarritoServiceImpl.java index c986fe3..745d87c 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/impl/CarritoServiceImpl.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/business/service/impl/CarritoServiceImpl.java @@ -9,6 +9,7 @@ import com.tfg.angel.gameswap.backend.business.service.CarritoService; import com.tfg.angel.gameswap.backend.exception.GSBadRequestException; import com.tfg.angel.gameswap.backend.exception.GSNotFoundException; +import com.tfg.angel.gameswap.backend.security.SecurityUtils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -46,9 +47,14 @@ public CarritoResponseDTO findByUser(Long idUsuario) { } @Override - public CarritoResponseDTO addProduct(Long idPostVenta, Long idUsuario) { + public CarritoResponseDTO addProduct(Long idPostVenta) { - Carrito carrito = carritoRepository.findByUsuarioId(idUsuario) + String username = SecurityUtils.getUsername(); + + Usuario usuario = usuarioRepository.findByNombreUsuario(username) + .orElseThrow(() -> new GSNotFoundException("Usuario no encontrado")); + + Carrito carrito = carritoRepository.findByUsuarioId(usuario.getId()) .orElseThrow(() -> new GSNotFoundException("Carrito no encontrado")); PostVenta postVenta = postVentaRepository.findById(idPostVenta) diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/config/SpringSecurityConfig.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/config/SpringSecurityConfig.java deleted file mode 100644 index e0c2fe8..0000000 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/config/SpringSecurityConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.tfg.angel.gameswap.backend.config; - -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.web.SecurityFilterChain; - -@Configuration -public class SpringSecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/v3/api-docs.yaml" - ).permitAll() - .anyRequest().permitAll() - ) - .httpBasic(httpBasic -> httpBasic.disable()) - .formLogin(form -> form.disable()); - - return http.build(); - } - -} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/AuthController.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/AuthController.java new file mode 100644 index 0000000..38b0bae --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/AuthController.java @@ -0,0 +1,109 @@ +package com.tfg.angel.gameswap.backend.security; + +import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; +import com.tfg.angel.gameswap.backend.exception.GSBadRequestException; +import com.tfg.angel.gameswap.backend.security.records.AuthRequest; +import com.tfg.angel.gameswap.backend.security.records.AuthResponse; +import com.tfg.angel.gameswap.backend.security.records.RegisterRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UsuarioRepository usuarioRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final TokenBlacklistService tokenBlacklistService; + + @PostMapping("/login") + public AuthResponse login(@RequestBody AuthRequest request) { + + var usuario = usuarioRepository.findByNombreUsuario(request.username()) + .orElseThrow(() -> new RuntimeException("Usuario no encontrado")); + + if (!passwordEncoder.matches(request.password(), usuario.getPassword())) { + throw new GSBadRequestException("Constraseña incorrecta"); + } + + String accessToken = jwtService.generateToken( + usuario.getNombreUsuario(), + usuario.getRol().name() + ); + + String refreshToken = jwtService.generateRefreshToken(usuario.getNombreUsuario()); + + return new AuthResponse(accessToken, refreshToken); + } + + @PostMapping("/register") + public AuthResponse register(@RequestBody RegisterRequest request) { + + if (usuarioRepository.existsByNombreUsuario(request.username())) { + throw new GSBadRequestException("El usuario ya existe"); + } + + var usuario = Usuario.builder() + .nombre(request.name()) + .nombreUsuario(request.username()) + .correo(request.correo()) + .rol(Rol.CLIENTE) + .saldo(0.0) + .estrellas(0.0) + .password(passwordEncoder.encode(request.password())) + .build(); + + usuarioRepository.save(usuario); + + String accessToken = jwtService.generateToken( + usuario.getNombreUsuario(), + usuario.getRol().name() + ); + + String refreshToken = jwtService.generateRefreshToken(usuario.getNombreUsuario()); + + return new AuthResponse(accessToken, refreshToken); + } + + @PostMapping("/refresh") + public String refresh(@RequestParam String refreshToken) { + + if (!jwtService.isValid(refreshToken)) { + throw new GSBadRequestException("Refresh token inválido"); + } + + String username = jwtService.extractUsername(refreshToken); + + var usuario = usuarioRepository.findByNombreUsuario(username) + .orElseThrow(); + + return jwtService.generateToken( + usuario.getNombreUsuario(), + usuario.getRol().name() + ); + } + + @PostMapping("/logout") + public void logout(HttpServletRequest request) { + + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + tokenBlacklistService.blacklist(token); + } + } + + @GetMapping("/admin/test") + @PreAuthorize("hasRole('ADMIN')") + public String adminOnly() { + return "ok"; + } +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/config/CorsConfig.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/CorsConfig.java similarity index 90% rename from backend/src/main/java/com/tfg/angel/gameswap/backend/config/CorsConfig.java rename to backend/src/main/java/com/tfg/angel/gameswap/backend/security/CorsConfig.java index 8da7d85..f824406 100644 --- a/backend/src/main/java/com/tfg/angel/gameswap/backend/config/CorsConfig.java +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/CorsConfig.java @@ -1,4 +1,4 @@ -package com.tfg.angel.gameswap.backend.config; +package com.tfg.angel.gameswap.backend.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -35,7 +35,7 @@ public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/api/**", config); + source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtFilter.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtFilter.java new file mode 100644 index 0000000..d2a0df1 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtFilter.java @@ -0,0 +1,56 @@ +package com.tfg.angel.gameswap.backend.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UsuarioDetailsService userDetailsService; + private final TokenBlacklistService tokenBlacklistService; + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain + ) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith("Bearer ")) { + + String token = header.substring(7); + + if (tokenBlacklistService.isBlacklisted(token)) { + chain.doFilter(request, response); + return; + } + + if (jwtService.isValid(token)) { + + String username = jwtService.extractUsername(token); + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + + var userDetails = userDetailsService.loadUserByUsername(username); + var auth = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + } + chain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtService.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtService.java new file mode 100644 index 0000000..f82bf62 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/JwtService.java @@ -0,0 +1,63 @@ +package com.tfg.angel.gameswap.backend.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; + +@Service +public class JwtService { + + private Key getKey() { + String secret = "esto_es_una_clave_super_segura_123456"; + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateToken(String username, String rol) { + // 1 hora + long expiration = 1000L * 60 * 60; + return Jwts.builder() + .setSubject(username) + .claim("rol", rol) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String generateRefreshToken(String username) { + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + (1000L * 60 * 60 * 24))) // 24 horas + .signWith(getKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String extractUsername(String token) { + return getClaims(token).getSubject(); + } + + public String extractRol(String token) { + return getClaims(token).get("rol", String.class); + } + + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/PasswordConfig.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/PasswordConfig.java new file mode 100644 index 0000000..cf28f4c --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/PasswordConfig.java @@ -0,0 +1,15 @@ +package com.tfg.angel.gameswap.backend.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SecurityUtils.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SecurityUtils.java new file mode 100644 index 0000000..0a76e89 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SecurityUtils.java @@ -0,0 +1,12 @@ +package com.tfg.angel.gameswap.backend.security; + +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtils { + + public static String getUsername() { + return SecurityContextHolder.getContext() + .getAuthentication() + .getName(); + } +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SpringSecurityConfig.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SpringSecurityConfig.java new file mode 100644 index 0000000..083a36f --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/SpringSecurityConfig.java @@ -0,0 +1,57 @@ +package com.tfg.angel.gameswap.backend.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.*; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.*; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableMethodSecurity +public class SpringSecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + return http + .csrf(csrf -> csrf.disable()) + .cors(cors -> {}) + + .authorizeHttpRequests(auth -> auth + + // Públicos + .requestMatchers( + "/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/v3/api-docs.yaml" + ).permitAll() + + // Admin + .requestMatchers("/auth/admin/**").hasRole("ADMIN") + + // API - Usuarios logueados + .requestMatchers("/api/**").authenticated() + + // Lo demás + .anyRequest().authenticated() + ) + + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + + .sessionManagement(sess -> + sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(form -> form.disable()) + + .build(); + } + +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/TokenBlacklistService.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/TokenBlacklistService.java new file mode 100644 index 0000000..0dc85ed --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/TokenBlacklistService.java @@ -0,0 +1,20 @@ +package com.tfg.angel.gameswap.backend.security; + +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Set; + +@Service +public class TokenBlacklistService { + + private final Set blacklist = new HashSet<>(); + + public void blacklist(String token) { + blacklist.add(token); + } + + public boolean isBlacklisted(String token) { + return blacklist.contains(token); + } +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetails.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetails.java new file mode 100644 index 0000000..15d62a2 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetails.java @@ -0,0 +1,44 @@ +package com.tfg.angel.gameswap.backend.security; + +import com.tfg.angel.gameswap.backend.business.model.Usuario; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.List; + +public class UsuarioDetails implements UserDetails { + + private final Usuario usuario; + + public UsuarioDetails(Usuario usuario) { + this.usuario = usuario; + } + + @Override + public List getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + usuario.getRol().name())); + } + + @Override + public String getPassword() { + return usuario.getPassword(); + } + + @Override + public String getUsername() { + return usuario.getNombreUsuario(); + } + + public Long getId() { + return usuario.getId(); + } + + @Override public boolean isAccountNonExpired() { return true; } + + @Override public boolean isAccountNonLocked() { return true; } + + @Override public boolean isCredentialsNonExpired() { return true; } + + @Override public boolean isEnabled() { return true; } + +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetailsService.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetailsService.java new file mode 100644 index 0000000..d6d1d47 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/UsuarioDetailsService.java @@ -0,0 +1,21 @@ +package com.tfg.angel.gameswap.backend.security; + +import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.*; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UsuarioDetailsService implements UserDetailsService { + + private final UsuarioRepository usuarioRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var usuario = usuarioRepository.findByNombreUsuario(username) + .orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado")); + + return new UsuarioDetails(usuario); + } +} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthRequest.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthRequest.java new file mode 100644 index 0000000..1b90817 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthRequest.java @@ -0,0 +1,3 @@ +package com.tfg.angel.gameswap.backend.security.records; + +public record AuthRequest(String username, String password) {} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthResponse.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthResponse.java new file mode 100644 index 0000000..fc23711 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/AuthResponse.java @@ -0,0 +1,3 @@ +package com.tfg.angel.gameswap.backend.security.records; + +public record AuthResponse(String accessToken, String refreshToken) {} diff --git a/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/RegisterRequest.java b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/RegisterRequest.java new file mode 100644 index 0000000..a141285 --- /dev/null +++ b/backend/src/main/java/com/tfg/angel/gameswap/backend/security/records/RegisterRequest.java @@ -0,0 +1,8 @@ +package com.tfg.angel.gameswap.backend.security.records; + +public record RegisterRequest( + String name, + String username, + String password, + String correo +) {} diff --git a/backend/src/main/resources/db/migration/V2__fixes.sql b/backend/src/main/resources/db/migration/V2__fixes.sql index 4b3ba33..a3568aa 100644 --- a/backend/src/main/resources/db/migration/V2__fixes.sql +++ b/backend/src/main/resources/db/migration/V2__fixes.sql @@ -46,46 +46,4 @@ ADD COLUMN id_post_intercambio INTEGER REFERENCES PostIntercambio(id); ALTER TABLE ProductoCarrito DROP COLUMN id_producto; ALTER TABLE ProductoCarrito -ADD COLUMN id_post_venta INTEGER REFERENCES PostVenta(id); - - --- 6. DATOS DE PRUEBA (opcional pero OK) - -INSERT INTO usuario (id, correo, estrellas, fecha_nacimiento, nombre, n_usuario, saldo) -VALUES -(1, 'victor@gmail.com', 2, CURRENT_DATE, 'Victor', 'Victor02', 90), -(2, 'andres@gmail.com', 3, CURRENT_DATE, 'Andrés', 'Andrés03', 110); - -INSERT INTO producto (id, estado, id_api, nombre) -VALUES -(1, 'NUEVO', 1, 'Tablet'), -(2, 'USADO', 2, 'Mesa'); - -INSERT INTO postventa (id, estado, precio, id_producto, id_vendedor) -VALUES -(1, 'ACTIVO', 30, 1, 2), -(2, 'FINALIZADO', 15, 1, 2); - -INSERT INTO postintercambio (id, estado, id_producto, id_producto_cambio, id_usuario) -VALUES -(1, 'ACTIVO', 1, 2, 2); - -INSERT INTO intercambio (id, fecha, id_post_intercambio, id_usuario_cambio) -VALUES -(1, CURRENT_DATE, 1, 2); - -INSERT INTO compraventa (id, fecha, precio, id_comprador, id_post_venta) -VALUES -(1, CURRENT_DATE, 100, 1, 1); - -INSERT INTO review (id, contenido, estrellas, id_reviewed, id_reviewer) -VALUES -(1, 'Muy bien', 3, 1, 2); - -INSERT INTO carrito (id, coste, id_usuario) -VALUES -(1, 40, 1); - -INSERT INTO productocarrito (id, id_carrito, id_post_venta) -VALUES -(1, 1, 2); \ No newline at end of file +ADD COLUMN id_post_venta INTEGER REFERENCES PostVenta(id); \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V3__add_rol_usuario.sql b/backend/src/main/resources/db/migration/V3__add_rol_usuario.sql new file mode 100644 index 0000000..b31a43a --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__add_rol_usuario.sql @@ -0,0 +1,57 @@ +-- V3: Se añade la columna rol_usuario y password a la tabla Usuario + +ALTER TABLE Usuario +ADD COLUMN rol VARCHAR(20) DEFAULT 'CLIENTE'; + +ALTER TABLE Usuario +ADD CONSTRAINT chk_usuario_rol CHECK (rol IN ('ADMIN', 'CLIENTE')); + +ALTER TABLE Usuario +ADD COLUMN password VARCHAR(255); +-- '$2a$10$bKGDwSWcxsXf4tngOy3X3uHgp9xR4d4lpkcd2o15XVjIIWQ6bUeia'; -> "1234" + +-- Datos de prueba + +INSERT INTO usuario (id, correo, estrellas, fecha_nacimiento, nombre, n_usuario, saldo, rol, password) +VALUES +(1, 'victor@gmail.com', 2, CURRENT_DATE, 'Victor', 'Victor02', 90, 'CLIENTE', '$2a$10$bKGDwSWcxsXf4tngOy3X3uHgp9xR4d4lpkcd2o15XVjIIWQ6bUeia'), +(2, 'andres@gmail.com', 3, CURRENT_DATE, 'Andrés', 'Andrés03', 110, 'CLIENTE', '$2a$10$bKGDwSWcxsXf4tngOy3X3uHgp9xR4d4lpkcd2o15XVjIIWQ6bUeia'), +(3, 'admin@gmail.com', 5, CURRENT_DATE, 'Admin', 'Admin01', 9999, 'ADMIN', '$2a$10$bKGDwSWcxsXf4tngOy3X3uHgp9xR4d4lpkcd2o15XVjIIWQ6bUeia'); + +INSERT INTO producto (id, estado, id_api, nombre) +VALUES +(1, 'NUEVO', 1, 'Tablet'), +(2, 'USADO', 2, 'Mesa'); + +INSERT INTO postventa (id, estado, precio, id_producto, id_vendedor) +VALUES +(1, 'ACTIVO', 30, 1, 2), +(2, 'FINALIZADO', 15, 1, 2); + +INSERT INTO postintercambio (id, estado, id_producto, id_producto_cambio, id_usuario) +VALUES +(1, 'ACTIVO', 1, 2, 2); + +INSERT INTO intercambio (id, fecha, id_post_intercambio, id_usuario_cambio) +VALUES +(1, CURRENT_DATE, 1, 2); + +INSERT INTO compraventa (id, fecha, precio, id_comprador, id_post_venta) +VALUES +(1, CURRENT_DATE, 100, 1, 1); + +INSERT INTO review (id, contenido, estrellas, id_reviewed, id_reviewer) +VALUES +(1, 'Muy bien', 3, 1, 2); + +INSERT INTO carrito (id, coste, id_usuario) +VALUES +(1, 40, 1); + +INSERT INTO productocarrito (id, id_carrito, id_post_venta) +VALUES +(1, 1, 2); + +-- Sincronizar la secuencia del ID con los datos insertados +CREATE SEQUENCE IF NOT EXISTS "usuario_id_seq" START WITH 4; +ALTER SEQUENCE "usuario_id_seq" RESTART WITH 4; \ No newline at end of file diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/AuthControllerTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/AuthControllerTest.java new file mode 100644 index 0000000..8592b25 --- /dev/null +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/AuthControllerTest.java @@ -0,0 +1,181 @@ +package com.tfg.angel.gameswap.backend.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; +import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; +import com.tfg.angel.gameswap.backend.security.AuthController; +import com.tfg.angel.gameswap.backend.security.JwtService; +import com.tfg.angel.gameswap.backend.security.TokenBlacklistService; +import com.tfg.angel.gameswap.backend.security.UsuarioDetailsService; +import com.tfg.angel.gameswap.backend.security.records.AuthRequest; +import com.tfg.angel.gameswap.backend.security.records.RegisterRequest; +import org.junit.jupiter.api.BeforeEach; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UsuarioDetailsService usuarioDetailsService; + + @MockitoBean + private UsuarioRepository usuarioRepository; + + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private PasswordEncoder passwordEncoder; + + @MockitoBean + private TokenBlacklistService tokenBlacklistService; + + private Usuario usuarioPrueba; + + @BeforeEach + void setUp() { + usuarioPrueba = Usuario.builder() + .nombreUsuario("angel") + .password("encodedPassword") + .rol(Rol.CLIENTE) + .build(); + } + + @Test + void login_Success() throws Exception { + AuthRequest request = new AuthRequest("angel", "password123"); + + when(usuarioRepository.findByNombreUsuario("angel")).thenReturn(Optional.of(usuarioPrueba)); + when(passwordEncoder.matches("password123", "encodedPassword")).thenReturn(true); + when(jwtService.generateToken(anyString(), anyString())).thenReturn("access-token"); + when(jwtService.generateRefreshToken(anyString())).thenReturn("refresh-token"); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").value("access-token")) + .andExpect(jsonPath("$.refreshToken").value("refresh-token")); + } + + @Test + void login_UserNotFound_ThrowsException() throws Exception { + AuthRequest request = new AuthRequest("desconocido", "password"); + + when(usuarioRepository.findByNombreUsuario("desconocido")).thenReturn(Optional.empty()); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); + } + + @Test + void login_WrongPassword_ThrowsBadRequest() throws Exception { + AuthRequest request = new AuthRequest("angel", "wrong"); + + when(usuarioRepository.findByNombreUsuario("angel")).thenReturn(Optional.of(usuarioPrueba)); + when(passwordEncoder.matches("wrong", "encodedPassword")).thenReturn(false); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void register_Success() throws Exception { + RegisterRequest request = new RegisterRequest("Angel", "angel", "angel@test.com", "pass"); + + when(usuarioRepository.existsByNombreUsuario("angel")).thenReturn(false); + when(passwordEncoder.encode(any())).thenReturn("encoded"); + when(jwtService.generateToken(anyString(), anyString())).thenReturn("token"); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + verify(usuarioRepository, times(1)).save(any(Usuario.class)); + } + + @Test + void register_UserAlreadyExists_ThrowsBadRequest() throws Exception { + RegisterRequest request = new RegisterRequest("Angel", "angel", "a@a.com", "pass"); + + when(usuarioRepository.existsByNombreUsuario("angel")).thenReturn(true); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void refresh_Success() throws Exception { + when(jwtService.isValid(anyString())).thenReturn(true); + when(jwtService.extractUsername(anyString())).thenReturn("angel"); + when(usuarioRepository.findByNombreUsuario("angel")).thenReturn(Optional.of(usuarioPrueba)); + when(jwtService.generateToken(anyString(), anyString())).thenReturn("new-token"); + + mockMvc.perform(post("/auth/refresh") + .param("refreshToken", "valid-refresh-token")) + .andExpect(status().isOk()) + .andExpect(content().string("new-token")); + } + + @Test + void refresh_InvalidToken_ThrowsBadRequest() throws Exception { + when(jwtService.isValid(anyString())).thenReturn(false); + + mockMvc.perform(post("/auth/refresh") + .param("refreshToken", "invalid-token")) + .andExpect(status().isBadRequest()); + } + + @Test + void logout_WithValidHeader_BlacklistsToken() throws Exception { + mockMvc.perform(post("/auth/logout") + .header("Authorization", "Bearer valid-token")) + .andExpect(status().isOk()); + + verify(tokenBlacklistService).blacklist("valid-token"); + } + + @Test + void logout_WithoutHeader_DoesNothing() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isOk()); + + verify(tokenBlacklistService, never()).blacklist(anyString()); + } + + @Test + void adminOnly_ReturnsOk() throws Exception { + mockMvc.perform(get("/auth/admin/test")) + .andExpect(status().isOk()) + .andExpect(content().string("ok")); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtFilterTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtFilterTest.java new file mode 100644 index 0000000..6d86906 --- /dev/null +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtFilterTest.java @@ -0,0 +1,145 @@ +package com.tfg.angel.gameswap.backend.auth; + +import com.tfg.angel.gameswap.backend.security.JwtFilter; +import com.tfg.angel.gameswap.backend.security.JwtService; +import com.tfg.angel.gameswap.backend.security.TokenBlacklistService; +import com.tfg.angel.gameswap.backend.security.UsuarioDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +import java.io.IOException; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private JwtService jwtService; + + @Mock + private UsuarioDetailsService userDetailsService; + + @Mock + private TokenBlacklistService tokenBlacklistService; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtFilter jwtFilter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void doFilterInternal_NoHeader_ProceedsChain() throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(null); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_InvalidHeaderFormat_ProceedsChain() throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn("Basic 12345"); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_TokenBlacklisted_ProceedsChainWithoutAuth() throws ServletException, IOException { + String token = "blacklisted-token"; + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + when(tokenBlacklistService.isBlacklisted(token)).thenReturn(true); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).isValid(anyString()); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_InvalidToken_ProceedsChainWithoutAuth() throws ServletException, IOException { + String token = "invalid-token"; + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + when(tokenBlacklistService.isBlacklisted(token)).thenReturn(false); + when(jwtService.isValid(token)).thenReturn(false); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void doFilterInternal_ValidToken_SetsAuthentication() throws ServletException, IOException { + String token = "valid-token"; + String username = "angel"; + UserDetails userDetails = mock(UserDetails.class); + + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + when(tokenBlacklistService.isBlacklisted(token)).thenReturn(false); + when(jwtService.isValid(token)).thenReturn(true); + when(jwtService.extractUsername(token)).thenReturn(username); + when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); + when(userDetails.getAuthorities()).thenReturn(new ArrayList<>()); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(userDetails, SecurityContextHolder.getContext().getAuthentication().getPrincipal()); + } + + @Test + void doFilterInternal_ValidTokenButAlreadyAuthenticated_DoesNotReload() throws ServletException, IOException { + + SecurityContextHolder.getContext().setAuthentication(mock(UsernamePasswordAuthenticationToken.class)); + + String token = "valid-token"; + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + when(tokenBlacklistService.isBlacklisted(token)).thenReturn(false); + when(jwtService.isValid(token)).thenReturn(true); + when(jwtService.extractUsername(token)).thenReturn("angel"); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + + verify(userDetailsService, never()).loadUserByUsername(anyString()); + } +} diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtServiceTest.java new file mode 100644 index 0000000..10751af --- /dev/null +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/JwtServiceTest.java @@ -0,0 +1,57 @@ +package com.tfg.angel.gameswap.backend.auth; + +import com.tfg.angel.gameswap.backend.security.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtServiceTest { + + private JwtService jwtService; + private final String USERNAME = "angel_test"; + + @BeforeEach + void setUp() { + jwtService = new JwtService(); + } + + @Test + void generateToken_ShouldCreateValidToken() { + String ROL = "ADMIN"; + String token = jwtService.generateToken(USERNAME, ROL); + + assertNotNull(token); + assertTrue(jwtService.isValid(token)); + assertEquals(USERNAME, jwtService.extractUsername(token)); + assertEquals(ROL, jwtService.extractRol(token)); + } + + @Test + void generateRefreshToken_ShouldBeValidAndHaveCorrectSubject() { + String refreshToken = jwtService.generateRefreshToken(USERNAME); + + assertNotNull(refreshToken); + assertTrue(jwtService.isValid(refreshToken)); + assertEquals(USERNAME, jwtService.extractUsername(refreshToken)); + assertNull(jwtService.extractRol(refreshToken)); + } + + @Test + void isValid_ShouldReturnFalseForInvalidToken() { + String invalidToken = "este.no.es.un.token.valido"; + + assertFalse(jwtService.isValid(invalidToken)); + } + + @Test + void isValid_ShouldReturnFalseForExpiredToken() { + assertFalse(jwtService.isValid("")); + assertFalse(jwtService.isValid(null)); + } + + @Test + void extractUsername_ShouldThrowExceptionForMalformedToken() { + assertThrows(Exception.class, () -> jwtService.extractUsername("token.mal.formado")); + } +} diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/UsuarioDetailsTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/UsuarioDetailsTest.java new file mode 100644 index 0000000..a0e6392 --- /dev/null +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/auth/UsuarioDetailsTest.java @@ -0,0 +1,60 @@ +package com.tfg.angel.gameswap.backend.auth; + +import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; +import com.tfg.angel.gameswap.backend.security.UsuarioDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import static org.junit.jupiter.api.Assertions.*; + +class UsuarioDetailsTest { + + private UsuarioDetails usuarioDetails; + + @BeforeEach + void setUp() { + Usuario usuario = Usuario.builder() + .id(1L) + .nombreUsuario("angel_test") + .password("password123") + .rol(Rol.ADMIN) + .build(); + + usuarioDetails = new UsuarioDetails(usuario); + } + + @Test + void getAuthorities_ShouldReturnRoleWithPrefix() { + + var authorities = usuarioDetails.getAuthorities(); + + assertNotNull(authorities); + assertEquals(1, authorities.size()); + assertTrue(authorities.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))); + } + + @Test + void getPassword_ShouldReturnUserPassword() { + assertEquals("password123", usuarioDetails.getPassword()); + } + + @Test + void getUsername_ShouldReturnNombreUsuario() { + assertEquals("angel_test", usuarioDetails.getUsername()); + } + + @Test + void getId_ShouldReturnUserId() { + assertEquals(1L, usuarioDetails.getId()); + } + + @Test + void booleanMethods_ShouldReturnTrue() { + assertTrue(usuarioDetails.isAccountNonExpired()); + assertTrue(usuarioDetails.isAccountNonLocked()); + assertTrue(usuarioDetails.isCredentialsNonExpired()); + assertTrue(usuarioDetails.isEnabled()); + } +} diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/controller/CarritoControllerTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/controller/CarritoControllerTest.java index 56c7553..6478859 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/controller/CarritoControllerTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/controller/CarritoControllerTest.java @@ -24,7 +24,7 @@ void testAllMethods() { controller.delete(1L); verify(service).findByUser(1L); - verify(service).addProduct(1L, 1L); + verify(service).addProduct(1L); verify(service).removeProduct(1L); verify(service).create(carritoRequestDTO); verify(service).delete(1L); diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/mapper/UsuarioMapperTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/mapper/UsuarioMapperTest.java index c644375..8045178 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/mapper/UsuarioMapperTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/mapper/UsuarioMapperTest.java @@ -2,6 +2,7 @@ import com.tfg.angel.gameswap.backend.business.mapper.UsuarioMapper; import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import org.junit.jupiter.api.Test; import java.time.LocalDate; @@ -20,6 +21,7 @@ void toDTO_ok() { .fechaNacimiento(LocalDate.now()) .saldo(100.0) .estrellas(5.0) + .rol(Rol.CLIENTE) .build(); var dto = UsuarioMapper.toDTO(usuario); @@ -31,5 +33,6 @@ void toDTO_ok() { assertEquals("a@gmail.com", dto.getCorreo()); assertEquals(100.0, dto.getSaldo()); assertEquals(5.0, dto.getEstrellas()); + assertEquals(Rol.CLIENTE, dto.getRol()); } } diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CarritoServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CarritoServiceTest.java index f122998..ad11a36 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CarritoServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CarritoServiceTest.java @@ -3,17 +3,18 @@ import com.tfg.angel.gameswap.backend.business.dto.request.CarritoRequestDTO; import com.tfg.angel.gameswap.backend.business.dto.response.CarritoResponseDTO; import com.tfg.angel.gameswap.backend.business.model.*; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.*; import com.tfg.angel.gameswap.backend.business.service.impl.CarritoServiceImpl; import com.tfg.angel.gameswap.backend.exception.GSBadRequestException; import com.tfg.angel.gameswap.backend.exception.GSNotFoundException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; +import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.Optional; @@ -44,7 +45,18 @@ class CarritoServiceTest { @BeforeEach void setUp() { - usuario = Usuario.builder().id(1L).nombre("Angel").build(); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication( + new UsernamePasswordAuthenticationToken("angelUser", null, List.of()) + ); + SecurityContextHolder.setContext(context); + + usuario = Usuario.builder() + .id(1L) + .nombre("Angel") + .nombreUsuario("angelUser") + .rol(Rol.CLIENTE) + .build(); carrito = Carrito.builder() .id(10L) @@ -59,11 +71,17 @@ void setUp() { .build(); } + @AfterEach + void cleanUp() { + SecurityContextHolder.clearContext(); + } + @Test @DisplayName("Debe crear un carrito vacío para un usuario") void create_Success() { CarritoRequestDTO dto = new CarritoRequestDTO(1L); + when(usuarioRepository.findById(1L)).thenReturn(Optional.of(usuario)); when(carritoRepository.save(any(Carrito.class))).thenReturn(carrito); @@ -77,11 +95,12 @@ void create_Success() { @DisplayName("Debe añadir un producto al carrito y actualizar el coste") void addProduct_Success() { + when(usuarioRepository.findByNombreUsuario("angelUser")).thenReturn(Optional.of(usuario)); when(carritoRepository.findByUsuarioId(1L)).thenReturn(Optional.of(carrito)); when(postVentaRepository.findById(100L)).thenReturn(Optional.of(postVenta)); when(productoCarritoRepository.existsByCarritoIdAndPostVentaId(10L, 100L)).thenReturn(false); - CarritoResponseDTO result = carritoService.addProduct(100L, 1L); + CarritoResponseDTO result = carritoService.addProduct(100L); assertNotNull(result); assertEquals(30.0, carrito.getCoste()); @@ -93,13 +112,15 @@ void addProduct_Success() { @DisplayName("Debe lanzar GSBadRequestException si el producto ya está en el carrito") void addProduct_ThrowsBadRequest_WhenAlreadyInCart() { + when(usuarioRepository.findByNombreUsuario("angelUser")).thenReturn(Optional.of(usuario)); when(carritoRepository.findByUsuarioId(1L)).thenReturn(Optional.of(carrito)); when(postVentaRepository.findById(100L)).thenReturn(Optional.of(postVenta)); when(productoCarritoRepository.existsByCarritoIdAndPostVentaId(10L, 100L)).thenReturn(true); assertThrows(GSBadRequestException.class, () -> - carritoService.addProduct(100L, 1L) + carritoService.addProduct(100L) ); + verify(carritoRepository, never()).save(any()); } @@ -108,6 +129,7 @@ void addProduct_ThrowsBadRequest_WhenAlreadyInCart() { void removeProduct_Success() { carrito.setCoste(50.0); + ProductoCarrito pc = ProductoCarrito.builder() .id(500L) .carrito(carrito) @@ -119,7 +141,7 @@ void removeProduct_Success() { CarritoResponseDTO result = carritoService.removeProduct(500L); assertNotNull(result); - assertEquals(20.0, carrito.getCoste()); // 50.0 - 30.0 + assertEquals(20.0, carrito.getCoste()); verify(productoCarritoRepository).delete(pc); verify(carritoRepository).save(carrito); } @@ -129,6 +151,7 @@ void removeProduct_Success() { void removeProduct_CostNotNegative() { carrito.setCoste(10.0); + ProductoCarrito pc = ProductoCarrito.builder() .id(500L) .carrito(carrito) @@ -148,6 +171,8 @@ void findByUser_ThrowsNotFound() { when(carritoRepository.findByUsuarioId(1L)).thenReturn(Optional.empty()); - assertThrows(GSNotFoundException.class, () -> carritoService.findByUser(1L)); + assertThrows(GSNotFoundException.class, () -> + carritoService.findByUser(1L) + ); } } \ No newline at end of file diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CompraVentaServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CompraVentaServiceTest.java index f950f30..f33d36b 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CompraVentaServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/CompraVentaServiceTest.java @@ -7,6 +7,7 @@ import com.tfg.angel.gameswap.backend.business.model.Usuario; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoPost; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoProducto; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.CompraVentaRepository; import com.tfg.angel.gameswap.backend.business.repository.PostVentaRepository; import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; @@ -58,12 +59,14 @@ void setUp() { comprador = Usuario.builder() .id(1L) .nombre("Comprador") + .rol(Rol.CLIENTE) .saldo(100.0) .build(); vendedor = Usuario.builder() .id(2L) .nombre("Vendedor") + .rol(Rol.CLIENTE) .saldo(50.0) .build(); diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/IntercambioServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/IntercambioServiceTest.java index 862f07a..d3eeb75 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/IntercambioServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/IntercambioServiceTest.java @@ -4,6 +4,7 @@ import com.tfg.angel.gameswap.backend.business.model.*; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoPost; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoProducto; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.*; import com.tfg.angel.gameswap.backend.business.service.impl.IntercambioServiceImpl; import com.tfg.angel.gameswap.backend.exception.GSBadRequestException; @@ -43,8 +44,8 @@ class IntercambioServiceTest { @BeforeEach void setUp() { - usuarioProducto = Usuario.builder().id(1L).nombre("Angel").build(); - usuarioIntercambio = Usuario.builder().id(2L).nombre("Juan").build(); + usuarioProducto = Usuario.builder().id(1L).nombre("Angel").rol(Rol.CLIENTE).build(); + usuarioIntercambio = Usuario.builder().id(2L).nombre("Juan").rol(Rol.CLIENTE).build(); Producto producto = Producto.builder() .id(1L) diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostIntercambioServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostIntercambioServiceTest.java index a3aa8c7..328c5e6 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostIntercambioServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostIntercambioServiceTest.java @@ -6,6 +6,7 @@ import com.tfg.angel.gameswap.backend.business.model.Producto; import com.tfg.angel.gameswap.backend.business.model.Usuario; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoProducto; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.PostIntercambioRepository; import com.tfg.angel.gameswap.backend.business.repository.ProductoRepository; import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; @@ -45,7 +46,7 @@ class PostIntercambioServiceTest { @BeforeEach void setUp() { - usuario = Usuario.builder().id(1L).nombre("Angel").build(); + usuario = Usuario.builder().id(1L).nombre("Angel").rol(Rol.CLIENTE).build(); producto = Producto.builder() .id(1L) diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostVentaServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostVentaServiceTest.java index 8d29700..f49067e 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostVentaServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/PostVentaServiceTest.java @@ -7,6 +7,7 @@ import com.tfg.angel.gameswap.backend.business.model.Usuario; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoPost; import com.tfg.angel.gameswap.backend.business.model.enums.EstadoProducto; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.PostVentaRepository; import com.tfg.angel.gameswap.backend.business.repository.ProductoRepository; import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; @@ -48,7 +49,7 @@ class PostVentaServiceTest { @BeforeEach void setUp() { - vendedor = Usuario.builder().id(1L).nombre("Angel").build(); + vendedor = Usuario.builder().id(1L).nombre("Angel").rol(Rol.CLIENTE).build(); producto = Producto.builder() .id(1L) diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/ReviewServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/ReviewServiceTest.java index 9a84aea..dd7a9c9 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/ReviewServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/ReviewServiceTest.java @@ -4,6 +4,7 @@ import com.tfg.angel.gameswap.backend.business.dto.response.ReviewResponseDTO; import com.tfg.angel.gameswap.backend.business.model.Review; import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.ReviewRepository; import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; import com.tfg.angel.gameswap.backend.business.service.impl.ReviewServiceImpl; @@ -42,8 +43,8 @@ class ReviewServiceTest { @BeforeEach void setUp() { - reviewer = Usuario.builder().id(1L).nombre("Angel").build(); - reviewed = Usuario.builder().id(2L).nombre("Juan").build(); + reviewer = Usuario.builder().id(1L).nombre("Angel").rol(Rol.CLIENTE).build(); + reviewed = Usuario.builder().id(2L).nombre("Juan").rol(Rol.CLIENTE).build(); validDto = ReviewRequestDTO.builder() .idReviewer(1L) diff --git a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/UsuarioServiceTest.java b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/UsuarioServiceTest.java index 211ccaf..95c4808 100644 --- a/backend/src/test/java/com/tfg/angel/gameswap/backend/service/UsuarioServiceTest.java +++ b/backend/src/test/java/com/tfg/angel/gameswap/backend/service/UsuarioServiceTest.java @@ -3,6 +3,7 @@ import com.tfg.angel.gameswap.backend.business.dto.request.UsuarioRequestDTO; import com.tfg.angel.gameswap.backend.business.dto.response.UsuarioResponseDTO; import com.tfg.angel.gameswap.backend.business.model.Usuario; +import com.tfg.angel.gameswap.backend.business.model.enums.Rol; import com.tfg.angel.gameswap.backend.business.repository.UsuarioRepository; import com.tfg.angel.gameswap.backend.business.service.impl.UsuarioServiceImpl; import com.tfg.angel.gameswap.backend.exception.GSBadRequestException; @@ -49,6 +50,7 @@ void setUp() { .nombre("Angel") .nombreUsuario("angel_dev") .correo("angel@example.com") + .rol(Rol.CLIENTE) .build(); } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html deleted file mode 100644 index 36093e1..0000000 --- a/frontend/src/app/app.component.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - - diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index a6b0ab9..3f8ae0f 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -14,16 +14,10 @@ describe('AppComponent', () => { expect(app).toBeTruthy(); }); - it(`should have the 'frontend' title`, () => { + it(`should have the title`, () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; - expect(app.title).toEqual('frontend'); + expect(app.title).toEqual('GameSwap'); }); - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); - }); }); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index b412f0c..a32fd2f 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,12 +1,23 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { ThemeService } from './core/services/theme.service'; @Component({ + standalone: true, selector: 'app-root', imports: [RouterOutlet], - templateUrl: './app.component.html', - styleUrl: './app.component.css' + template: ` + + `, + styleUrls: ['./app.component.css'] }) -export class AppComponent { - title = 'frontend'; +export class AppComponent implements OnInit { + + constructor(private readonly theme: ThemeService) {} + + title = 'GameSwap'; + + ngOnInit() { + this.theme.initTheme(); + } } diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 72a148e..609041c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,9 +1,16 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { authInterceptor } from './core/interceptors/auth.interceptor'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()] + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient( + withInterceptors([authInterceptor]) + ) + ] }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index cd82dcc..1eff926 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,6 +1,44 @@ import { Routes } from '@angular/router'; -import { UsuariosComponent } from './usuarios/usuarios.component'; +import { UsuariosComponent } from './features/usuarios/usuarios.component'; +import { authGuard } from './core/guards/auth.guard'; export const routes: Routes = [ - { path: 'usuarios', component: UsuariosComponent } + { path: 'usuarios', component: UsuariosComponent }, + + { + path: '', + loadComponent: () => + import('./layout/main-layout.component') + .then(m => m.MainLayoutComponent), + canActivate: [authGuard], + children: [ + { + path: '', + loadComponent: () => + import('./features/home/home.component') + .then(m => m.HomeComponent) + } + ] + }, + + { + path: 'login', + loadComponent: () => + import('./features/auth/login/login.component') + .then(m => m.LoginComponent) + }, + + { + path: 'register', + loadComponent: () => + import('./features/auth/register/register.component') + .then(m => m.RegisterComponent) + }, + + { + path: 'perfil', + loadComponent: () => + import('./features/perfil/perfil.component') + .then(m => m.PerfilComponent) + } ]; diff --git a/frontend/src/app/core/guards/auth.guard.ts b/frontend/src/app/core/guards/auth.guard.ts new file mode 100644 index 0000000..0a2f57e --- /dev/null +++ b/frontend/src/app/core/guards/auth.guard.ts @@ -0,0 +1,18 @@ +/* sonar-ignore */ +/* istanbul ignore file */ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { TokenService } from '../services/token.service'; + +export const authGuard: CanActivateFn = () => { + + const tokenService = inject(TokenService); + const router = inject(Router); + + if (tokenService.isLogged()) { + return true; + } + + router.navigate(['/login']); + return false; +}; \ No newline at end of file diff --git a/frontend/src/app/core/interceptors/auth.interceptor.ts b/frontend/src/app/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..c5c1428 --- /dev/null +++ b/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -0,0 +1,21 @@ +/* sonar-ignore */ +/* istanbul ignore file */ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { TokenService } from '../services/token.service'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + + const tokenService = inject(TokenService); + const token = tokenService.getToken(); + + if (token) { + req = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + } + + return next(req); +}; \ No newline at end of file diff --git a/frontend/src/app/models/usuario.model.ts b/frontend/src/app/core/models/usuario.model.ts similarity index 100% rename from frontend/src/app/models/usuario.model.ts rename to frontend/src/app/core/models/usuario.model.ts diff --git a/frontend/src/app/core/services/auth.service.spec.ts b/frontend/src/app/core/services/auth.service.spec.ts new file mode 100644 index 0000000..177a73b --- /dev/null +++ b/frontend/src/app/core/services/auth.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { AuthService } from './auth.service'; +import { TokenService } from './token.service'; +import { environment } from '../../../environments/environment'; + +describe('AuthService', () => { + let service: AuthService; + let httpMock: HttpTestingController; + let tokenServiceSpy: jasmine.SpyObj; + + const mockResponse = { accessToken: 'fake-jwt-token' }; + const mockData = { email: 'test@test.com', password: '123' }; + const apiUrl = environment.authUrl; + + beforeEach(() => { + const spy = jasmine.createSpyObj('TokenService', ['setToken', 'removeToken']); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + AuthService, + { provide: TokenService, useValue: spy } + ] + }); + + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + tokenServiceSpy = TestBed.inject(TokenService) as jasmine.SpyObj; + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('debe crearse correctamente', () => { + expect(service).toBeTruthy(); + }); + + it('login() debe realizar un POST y guardar el token al tener éxito', () => { + service.login(mockData).subscribe((res) => { + expect(res).toEqual(mockResponse); + expect(tokenServiceSpy.setToken).toHaveBeenCalledWith(mockResponse.accessToken); + }); + + const req = httpMock.expectOne(`${apiUrl}/login`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockData); + req.flush(mockResponse); + }); + + it('register() debe realizar un POST y guardar el token al tener éxito', () => { + service.register(mockData).subscribe((res) => { + expect(res).toEqual(mockResponse); + expect(tokenServiceSpy.setToken).toHaveBeenCalledWith(mockResponse.accessToken); + }); + + const req = httpMock.expectOne(`${apiUrl}/register`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockData); + req.flush(mockResponse); + }); + + it('logout() debe eliminar el token a través del TokenService', () => { + service.logout(); + expect(tokenServiceSpy.removeToken).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..2aeff89 --- /dev/null +++ b/frontend/src/app/core/services/auth.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { TokenService } from './token.service'; +import { tap } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + + private readonly API = `${environment.authUrl}`; + + constructor( + private readonly http: HttpClient, + private readonly tokenService: TokenService + ) {} + + login(data: any) { + return this.http.post(`${this.API}/login`, data) + .pipe( + tap(res => this.tokenService.setToken(res.accessToken)) + ); + } + + register(data: any) { + return this.http.post(`${this.API}/register`, data) + .pipe( + tap(res => this.tokenService.setToken(res.accessToken)) + ); + } + + logout() { + this.tokenService.removeToken(); + } +} \ No newline at end of file diff --git a/frontend/src/app/core/services/theme.service.spec.ts b/frontend/src/app/core/services/theme.service.spec.ts new file mode 100644 index 0000000..247bc8e --- /dev/null +++ b/frontend/src/app/core/services/theme.service.spec.ts @@ -0,0 +1,62 @@ +import { TestBed } from '@angular/core/testing'; +import { ThemeService } from './theme.service'; + +describe('ThemeService', () => { + let service: ThemeService; + const THEME_KEY = 'theme'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ThemeService] + }); + service = TestBed.inject(ThemeService); + localStorage.clear(); + document.body.className = ''; + }); + + afterEach(() => { + localStorage.clear(); + document.body.className = ''; + }); + + it('debe crearse correctamente', () => { + expect(service).toBeTruthy(); + }); + + it('initTheme() debe establecer "light" por defecto si el localStorage está vacío', () => { + service.initTheme(); + expect(document.body.className).toBe('light'); + }); + + it('initTheme() debe establecer el tema desde el localStorage si existe', () => { + localStorage.setItem(THEME_KEY, 'dark'); + service.initTheme(); + expect(document.body.className).toBe('dark'); + }); + + it('toggleTheme() debe cambiar de light a dark', () => { + document.body.classList.add('light'); + service.toggleTheme(); + expect(document.body.classList.contains('dark')).toBeTrue(); + expect(document.body.classList.contains('light')).toBeFalse(); + expect(localStorage.getItem(THEME_KEY)).toBe('dark'); + }); + + it('toggleTheme() debe cambiar de dark a light', () => { + document.body.classList.add('dark'); + service.toggleTheme(); + expect(document.body.classList.contains('light')).toBeTrue(); + expect(document.body.classList.contains('dark')).toBeFalse(); + expect(localStorage.getItem(THEME_KEY)).toBe('light'); + }); + + it('isDark() debe retornar true si el body tiene la clase "dark"', () => { + document.body.classList.add('dark'); + expect(service.isDark()).toBeTrue(); + }); + + it('isDark() debe retornar false si el body no tiene la clase "dark"', () => { + document.body.classList.add('light'); + expect(service.isDark()).toBeFalse(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/core/services/theme.service.ts b/frontend/src/app/core/services/theme.service.ts new file mode 100644 index 0000000..c5f77af --- /dev/null +++ b/frontend/src/app/core/services/theme.service.ts @@ -0,0 +1,27 @@ +/* sonar-ignore */ +/* istanbul ignore file */ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + + private readonly THEME_KEY = 'theme'; + + initTheme() { + const theme = localStorage.getItem(this.THEME_KEY) || 'light'; + document.body.className = theme; + } + + toggleTheme() { + const current = document.body.classList.contains('dark') ? 'dark' : 'light'; + const newTheme = current === 'dark' ? 'light' : 'dark'; + + document.body.classList.remove('light', 'dark'); + document.body.classList.add(newTheme); + localStorage.setItem(this.THEME_KEY, newTheme); + } + + isDark() { + return document.body.classList.contains('dark'); + } +} \ No newline at end of file diff --git a/frontend/src/app/core/services/token.service.spec.ts b/frontend/src/app/core/services/token.service.spec.ts new file mode 100644 index 0000000..695745f --- /dev/null +++ b/frontend/src/app/core/services/token.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { TokenService } from './token.service'; + +describe('TokenService', () => { + let service: TokenService; + const KEY = 'token'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TokenService] + }); + service = TestBed.inject(TokenService); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('debe crearse correctamente', () => { + expect(service).toBeTruthy(); + }); + + it('setToken() debe guardar el token en localStorage', () => { + const token = 'mi-token-de-prueba'; + service.setToken(token); + expect(localStorage.getItem(KEY)).toBe(token); + }); + + it('getToken() debe obtener el token desde localStorage', () => { + const token = 'mi-token-de-prueba'; + localStorage.setItem(KEY, token); + expect(service.getToken()).toBe(token); + }); + + it('removeToken() debe eliminar el token de localStorage', () => { + localStorage.setItem(KEY, 'token-a-borrar'); + service.removeToken(); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it('isLogged() debe retornar true si existe un token', () => { + localStorage.setItem(KEY, 'token-existente'); + expect(service.isLogged()).toBeTrue(); + }); + + it('isLogged() debe retornar false si no existe un token', () => { + expect(service.isLogged()).toBeFalse(); + }); + + it('getUsername() debe retornar null si no hay token', () => { + expect(service.getUsername()).toBeNull(); + }); + + it('getUsername() debe decodificar el payload y retornar el "sub"', () => { + const payload = { sub: 'usuario_test', exp: 123456789 }; + const payloadEncoded = btoa(JSON.stringify(payload)); + const fakeJwt = `header.${payloadEncoded}.signature`; + + localStorage.setItem(KEY, fakeJwt); + + expect(service.getUsername()).toBe('usuario_test'); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/core/services/token.service.ts b/frontend/src/app/core/services/token.service.ts new file mode 100644 index 0000000..b73c99e --- /dev/null +++ b/frontend/src/app/core/services/token.service.ts @@ -0,0 +1,33 @@ +/* sonar-ignore */ +/* istanbul ignore file */ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class TokenService { + + private readonly KEY = 'token'; + + setToken(token: string) { + localStorage.setItem(this.KEY, token); + } + + getToken(): string | null { + return localStorage.getItem(this.KEY); + } + + removeToken() { + localStorage.removeItem(this.KEY); + } + + isLogged(): boolean { + return !!this.getToken(); + } + + getUsername(): string | null { + const token = this.getToken(); + if (!token) return null; + + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.sub; + } +} \ No newline at end of file diff --git a/frontend/src/app/core/services/usuario.service.spec.ts b/frontend/src/app/core/services/usuario.service.spec.ts new file mode 100644 index 0000000..6724ff7 --- /dev/null +++ b/frontend/src/app/core/services/usuario.service.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { UsuarioService } from './usuario.service'; +import { Usuario } from '../models/usuario.model'; +import { environment } from '../../../environments/environment'; + +describe('UsuarioService', () => { + let service: UsuarioService; + let httpMock: HttpTestingController; + const apiUrl = `${environment.apiUrl}/usuarios`; + + const mockUsuarios: Usuario[] = [ + { id: 1, nombre: 'Usuario 1', email: 'user1@test.com' } as any, + { id: 2, nombre: 'Usuario 2', email: 'user2@test.com' } as any + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [UsuarioService] + }); + service = TestBed.inject(UsuarioService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('debe crearse correctamente', () => { + expect(service).toBeTruthy(); + }); + + it('getUsuarios() debe retornar una lista de usuarios mediante GET', () => { + service.getUsuarios().subscribe((usuarios) => { + expect(usuarios.length).toBe(2); + expect(usuarios).toEqual(mockUsuarios); + }); + + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + req.flush(mockUsuarios); + }); + + it('crearUsuario() debe enviar un nuevo usuario mediante POST y retornarlo', () => { + const nuevoUsuario: Usuario = { nombre: 'Nuevo', email: 'nuevo@test.com' } as any; + + service.crearUsuario(nuevoUsuario).subscribe((usuario) => { + expect(usuario).toEqual(nuevoUsuario); + }); + + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(nuevoUsuario); + req.flush(nuevoUsuario); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/services/usuario.service.ts b/frontend/src/app/core/services/usuario.service.ts similarity index 89% rename from frontend/src/app/services/usuario.service.ts rename to frontend/src/app/core/services/usuario.service.ts index 70fc214..38ffd7a 100644 --- a/frontend/src/app/services/usuario.service.ts +++ b/frontend/src/app/core/services/usuario.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Usuario } from '../models/usuario.model'; -import { environment } from '../../environments/environment'; +import { environment } from '../../../environments/environment'; @Injectable({ providedIn: 'root' diff --git a/frontend/src/app/features/auth/login/login.component.css b/frontend/src/app/features/auth/login/login.component.css new file mode 100644 index 0000000..5a82dcf --- /dev/null +++ b/frontend/src/app/features/auth/login/login.component.css @@ -0,0 +1,25 @@ +.auth-container { + max-width: 400px; + margin: auto; + padding: 2rem; +} + +input { + width: 100%; + margin: 10px 0; + padding: 12px; + border-radius: 8px; + border: 1px solid #ccc; +} + +@media (max-width: 600px) { + .auth-container { + padding: 1rem; + } +} + +.link { + cursor: pointer; + color: blue; + text-decoration: underline; +} \ No newline at end of file diff --git a/frontend/src/app/features/auth/login/login.component.html b/frontend/src/app/features/auth/login/login.component.html new file mode 100644 index 0000000..25913d8 --- /dev/null +++ b/frontend/src/app/features/auth/login/login.component.html @@ -0,0 +1,22 @@ +
+

Login

+ +
+ + + + + +
+ + +
\ No newline at end of file diff --git a/frontend/src/app/features/auth/login/login.component.spec.ts b/frontend/src/app/features/auth/login/login.component.spec.ts new file mode 100644 index 0000000..2f3e8a1 --- /dev/null +++ b/frontend/src/app/features/auth/login/login.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LoginComponent } from './login.component'; +import { AuthService } from '../../../core/services/auth.service'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { of } from 'rxjs'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + // Mocks + const authServiceMock = { + login: jasmine.createSpy('login').and.returnValue(of({})) + }; + const routerMock = { + navigate: jasmine.createSpy('navigate') + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent, FormsModule], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: Router, useValue: routerMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('debería crear el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debería llamar a auth.login y navegar al inicio al hacer login', () => { + component.username = 'testuser'; + component.password = '123456'; + + component.login(); + + expect(authServiceMock.login).toHaveBeenCalledWith({ + username: 'testuser', + password: '123456' + }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + }); + + it('debería navegar a /register al llamar a goRegister', () => { + component.goRegister(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/register']); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/auth/login/login.component.ts b/frontend/src/app/features/auth/login/login.component.ts new file mode 100644 index 0000000..f11d6df --- /dev/null +++ b/frontend/src/app/features/auth/login/login.component.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [FormsModule], + templateUrl: './login.component.html', + styleUrls: ['./login.component.css'] +}) +export class LoginComponent { + username = ''; + password = ''; + + constructor(private readonly auth: AuthService, private readonly router: Router) {} + + login() { + this.auth.login({ + username: this.username, + password: this.password + }).subscribe(() => { + this.router.navigate(['/']); + }); + } + + goRegister() { + this.router.navigate(['/register']); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/auth/register/register.component.css b/frontend/src/app/features/auth/register/register.component.css new file mode 100644 index 0000000..5a82dcf --- /dev/null +++ b/frontend/src/app/features/auth/register/register.component.css @@ -0,0 +1,25 @@ +.auth-container { + max-width: 400px; + margin: auto; + padding: 2rem; +} + +input { + width: 100%; + margin: 10px 0; + padding: 12px; + border-radius: 8px; + border: 1px solid #ccc; +} + +@media (max-width: 600px) { + .auth-container { + padding: 1rem; + } +} + +.link { + cursor: pointer; + color: blue; + text-decoration: underline; +} \ No newline at end of file diff --git a/frontend/src/app/features/auth/register/register.component.html b/frontend/src/app/features/auth/register/register.component.html new file mode 100644 index 0000000..df88f32 --- /dev/null +++ b/frontend/src/app/features/auth/register/register.component.html @@ -0,0 +1,14 @@ +
+

Registro

+ +
+ + + + + + +
+ + +
\ No newline at end of file diff --git a/frontend/src/app/features/auth/register/register.component.spec.ts b/frontend/src/app/features/auth/register/register.component.spec.ts new file mode 100644 index 0000000..6afbd4a --- /dev/null +++ b/frontend/src/app/features/auth/register/register.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RegisterComponent } from './register.component'; +import { AuthService } from '../../../core/services/auth.service'; +import { Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { of } from 'rxjs'; + +describe('RegisterComponent', () => { + let component: RegisterComponent; + let fixture: ComponentFixture; + + const authServiceMock = { + register: jasmine.createSpy('register').and.returnValue(of({})) + }; + const routerMock = { + navigate: jasmine.createSpy('navigate') + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegisterComponent, FormsModule], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: Router, useValue: routerMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(RegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('debería crear el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debería llamar a auth.register con los datos correctos y navegar', () => { + component.name = 'Juan'; + component.username = 'juanito'; + component.email = 'juan@test.com'; + component.password = 'password123'; + + component.register(); + + expect(authServiceMock.register).toHaveBeenCalledWith({ + name: 'Juan', + username: 'juanito', + correo: 'juan@test.com', + password: 'password123' + }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + }); + + it('debería navegar a login al llamar a goLogin', () => { + component.goLogin(); + expect(routerMock.navigate).toHaveBeenCalledWith(['/login']); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/auth/register/register.component.ts b/frontend/src/app/features/auth/register/register.component.ts new file mode 100644 index 0000000..734f858 --- /dev/null +++ b/frontend/src/app/features/auth/register/register.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [FormsModule], + templateUrl: './register.component.html', + styleUrls: ['./register.component.css'] +}) +export class RegisterComponent { + name = ''; + username = ''; + email = ''; + password = ''; + + constructor(private readonly auth: AuthService, private readonly router: Router) {} + + register() { + this.auth.register({ + name: this.name, + username: this.username, + correo: this.email, + password: this.password + }).subscribe(() => { + this.router.navigate(['/']); + }); + } + + goLogin() { + this.router.navigate(['/login']); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/home/home.component.css b/frontend/src/app/features/home/home.component.css new file mode 100644 index 0000000..2fe9e2d --- /dev/null +++ b/frontend/src/app/features/home/home.component.css @@ -0,0 +1,23 @@ +.home { + text-align: center; + padding: 1rem; +} + +.cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-top: 2rem; +} + +.card { + padding: 1rem; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +@media (max-width: 768px) { + .cards { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html new file mode 100644 index 0000000..843f845 --- /dev/null +++ b/frontend/src/app/features/home/home.component.html @@ -0,0 +1,21 @@ +
+

Bienvenido a GameSwap 🎮

+

Compra, vende e intercambia videojuegos fácilmente.

+ +
+
+

Explorar juegos

+

Descubre lo que otros usuarios ofrecen

+
+ +
+

Publicar

+

Vende o intercambia tus juegos

+
+ +
+

Intercambios

+

Gestiona tus propuestas

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/features/home/home.component.spec.ts b/frontend/src/app/features/home/home.component.spec.ts new file mode 100644 index 0000000..5f9a76b --- /dev/null +++ b/frontend/src/app/features/home/home.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HomeComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('debería crear el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debería mostrar el título de bienvenida', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Bienvenido a GameSwap'); + }); + + it('debería renderizar las tres tarjetas de opciones', () => { + const compiled = fixture.nativeElement as HTMLElement; + const cards = compiled.querySelectorAll('.card'); + expect(cards.length).toBe(3); + }); + + it('debería tener la primera tarjeta con el texto Explorar juegos', () => { + const compiled = fixture.nativeElement as HTMLElement; + const firstCardTitle = compiled.querySelectorAll('.card h3')[0]; + expect(firstCardTitle.textContent).toBe('Explorar juegos'); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts new file mode 100644 index 0000000..0b2816a --- /dev/null +++ b/frontend/src/app/features/home/home.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-home', + standalone: true, + templateUrl: './home.component.html', + styleUrls: ['./home.component.css'] +}) +export class HomeComponent {} \ No newline at end of file diff --git a/frontend/src/app/features/perfil/perfil.component.css b/frontend/src/app/features/perfil/perfil.component.css new file mode 100644 index 0000000..d2f798e --- /dev/null +++ b/frontend/src/app/features/perfil/perfil.component.css @@ -0,0 +1,33 @@ +.perfil-container { + max-width: 500px; + margin: auto; + padding: 1rem; +} + +.card { + background: var(--card); + padding: 1.5rem; + border-radius: 10px; +} + +.field { + margin-bottom: 1rem; +} + +label { + display: block; + font-weight: bold; + margin-bottom: 5px; +} + +input { + width: 100%; + padding: 10px; + border-radius: 8px; + border: 1px solid #ccc; +} + +.actions { + display: flex; + justify-content: flex-end; +} \ No newline at end of file diff --git a/frontend/src/app/features/perfil/perfil.component.html b/frontend/src/app/features/perfil/perfil.component.html new file mode 100644 index 0000000..cb17610 --- /dev/null +++ b/frontend/src/app/features/perfil/perfil.component.html @@ -0,0 +1,42 @@ +
+ +
+

Mi Perfil

+ Volver +
+ +
+ +
+ + + + + + {{ usuario.nombre }} + +
+ +
+ + + + + + {{ usuario.email }} + +
+ +
+ + + +
+ +
+ +
\ No newline at end of file diff --git a/frontend/src/app/features/perfil/perfil.component.ts b/frontend/src/app/features/perfil/perfil.component.ts new file mode 100644 index 0000000..0bef7c6 --- /dev/null +++ b/frontend/src/app/features/perfil/perfil.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgIf } from '@angular/common'; +import { RouterLink } from '@angular/router'; + +@Component({ + standalone: true, + imports: [FormsModule, NgIf, RouterLink], + templateUrl: './perfil.component.html', + styleUrls: ['./perfil.component.css'] +}) +export class PerfilComponent { + + usuario = { + nombre: 'Angel', + email: 'angel@gmail.com' + }; + + editMode = false; + + toggleEdit() { + this.editMode = !this.editMode; + } + + guardar() { + console.log('Guardar usuario:', this.usuario); + this.editMode = false; + } +} \ No newline at end of file diff --git a/frontend/src/app/usuarios/usuarios.component.css b/frontend/src/app/features/usuarios/usuarios.component.css similarity index 100% rename from frontend/src/app/usuarios/usuarios.component.css rename to frontend/src/app/features/usuarios/usuarios.component.css diff --git a/frontend/src/app/usuarios/usuarios.component.html b/frontend/src/app/features/usuarios/usuarios.component.html similarity index 100% rename from frontend/src/app/usuarios/usuarios.component.html rename to frontend/src/app/features/usuarios/usuarios.component.html diff --git a/frontend/src/app/features/usuarios/usuarios.component.spec.ts b/frontend/src/app/features/usuarios/usuarios.component.spec.ts new file mode 100644 index 0000000..2076763 --- /dev/null +++ b/frontend/src/app/features/usuarios/usuarios.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UsuariosComponent } from './usuarios.component'; +import { UsuarioService } from '../../core/services/usuario.service'; +import { of } from 'rxjs'; +import { Usuario } from '../../core/models/usuario.model'; + +describe('UsuariosComponent', () => { + let component: UsuariosComponent; + let fixture: ComponentFixture; + let usuarioServiceMock: any; + + const mockUsuarios: Usuario[] = [ + { nombre: 'Usuario 1', email: 'u1@test.com' }, + { nombre: 'Usuario 2', email: 'u2@test.com' } + ]; + + beforeEach(async () => { + + usuarioServiceMock = { + getUsuarios: jasmine.createSpy('getUsuarios').and.returnValue(of(mockUsuarios)), + crearUsuario: jasmine.createSpy('crearUsuario').and.returnValue(of({})) + }; + + await TestBed.configureTestingModule({ + imports: [UsuariosComponent], + providers: [ + { provide: UsuarioService, useValue: usuarioServiceMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(UsuariosComponent); + component = fixture.componentInstance; + }); + + it('debe crearse el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debe cargar usuarios al inicializar (ngOnInit)', () => { + fixture.detectChanges(); + + expect(usuarioServiceMock.getUsuarios).toHaveBeenCalled(); + expect(component.usuarios.length).toBe(2); + expect(component.usuarios).toEqual(mockUsuarios); + }); + + it('debe llamar a cargarUsuarios y actualizar la lista', () => { + component.cargarUsuarios(); + + expect(usuarioServiceMock.getUsuarios).toHaveBeenCalled(); + expect(component.usuarios).toEqual(mockUsuarios); + }); + + it('debe crear un usuario y volver a cargar la lista', () => { + + fixture.detectChanges(); + + usuarioServiceMock.getUsuarios.calls.reset(); + + component.crearUsuario(); + + expect(usuarioServiceMock.crearUsuario).toHaveBeenCalledWith(jasmine.objectContaining({ + nombre: 'Angular Test' + })); + + expect(usuarioServiceMock.getUsuarios).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/usuarios/usuarios.component.ts b/frontend/src/app/features/usuarios/usuarios.component.ts similarity index 86% rename from frontend/src/app/usuarios/usuarios.component.ts rename to frontend/src/app/features/usuarios/usuarios.component.ts index 80924e9..b17ed9b 100644 --- a/frontend/src/app/usuarios/usuarios.component.ts +++ b/frontend/src/app/features/usuarios/usuarios.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { UsuarioService } from '../services/usuario.service'; -import { Usuario } from '../models/usuario.model'; +import { UsuarioService } from '../../core/services/usuario.service'; +import { Usuario } from '../../core/models/usuario.model'; @Component({ selector: 'app-usuarios', diff --git a/frontend/src/app/layout/main-layout.component.css b/frontend/src/app/layout/main-layout.component.css new file mode 100644 index 0000000..e1429bc --- /dev/null +++ b/frontend/src/app/layout/main-layout.component.css @@ -0,0 +1,6 @@ +.content { + min-height: calc(100vh - 160px); + padding: 1rem; + display: block; +} + diff --git a/frontend/src/app/layout/main-layout.component.html b/frontend/src/app/layout/main-layout.component.html new file mode 100644 index 0000000..deedfdb --- /dev/null +++ b/frontend/src/app/layout/main-layout.component.html @@ -0,0 +1,7 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/app/layout/main-layout.component.spec.ts b/frontend/src/app/layout/main-layout.component.spec.ts new file mode 100644 index 0000000..067688e --- /dev/null +++ b/frontend/src/app/layout/main-layout.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MainLayoutComponent } from './main-layout.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NavbarComponent } from '../shared/components/navbar/navbar.component'; +import { FooterComponent } from '../shared/components/footer/footer.component'; +import { AuthService } from '../core/services/auth.service'; + +describe('MainLayoutComponent', () => { + let component: MainLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + + const authServiceMock = { logout: jasmine.createSpy('logout') }; + + await TestBed.configureTestingModule({ + imports: [ + MainLayoutComponent, + RouterTestingModule, + NavbarComponent, + FooterComponent + ], + providers: [ + { provide: AuthService, useValue: authServiceMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(MainLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('debería crear el layout', () => { + expect(component).toBeTruthy(); + }); + + it('debería contener el navbar, el footer y el outlet', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('app-navbar')).not.toBeNull(); + expect(compiled.querySelector('app-footer')).not.toBeNull(); + expect(compiled.querySelector('router-outlet')).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/layout/main-layout.component.ts b/frontend/src/app/layout/main-layout.component.ts new file mode 100644 index 0000000..a578d71 --- /dev/null +++ b/frontend/src/app/layout/main-layout.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { FooterComponent } from '../shared/components/footer/footer.component'; +import { NavbarComponent } from '../shared/components/navbar/navbar.component'; + +@Component({ + selector: 'app-main-layout', + standalone: true, + imports: [RouterOutlet, NavbarComponent, FooterComponent], + templateUrl: './main-layout.component.html', + styleUrls: ['./main-layout.component.css'] +}) +export class MainLayoutComponent {} \ No newline at end of file diff --git a/frontend/src/app/shared/components/footer/footer.component.css b/frontend/src/app/shared/components/footer/footer.component.css new file mode 100644 index 0000000..16fd98d --- /dev/null +++ b/frontend/src/app/shared/components/footer/footer.component.css @@ -0,0 +1,7 @@ +.footer { + background: #111; + color: white; + text-align: center; + padding: 0.5rem 0; + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/footer/footer.component.html b/frontend/src/app/shared/components/footer/footer.component.html new file mode 100644 index 0000000..add75db --- /dev/null +++ b/frontend/src/app/shared/components/footer/footer.component.html @@ -0,0 +1,4 @@ +
+

©2026 GameSwap

+

Contacto: gameswap@email.com

+
\ No newline at end of file diff --git a/frontend/src/app/shared/components/footer/footer.component.spec.ts b/frontend/src/app/shared/components/footer/footer.component.spec.ts new file mode 100644 index 0000000..0d1019b --- /dev/null +++ b/frontend/src/app/shared/components/footer/footer.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FooterComponent } from './footer.component'; + +describe('FooterComponent', () => { + let component: FooterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FooterComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('debería crear el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debería mostrar el texto del copyright con el año 2026', () => { + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('p')?.textContent).toContain('©2026 GameSwap'); + }); + + it('debería mostrar el email de contacto correctamente', () => { + const compiled = fixture.nativeElement as HTMLElement; + const paragraphs = compiled.querySelectorAll('p'); + // El segundo párrafo debería contener el email + expect(paragraphs[1].textContent).toContain('gameswap@email.com'); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/shared/components/footer/footer.component.ts b/frontend/src/app/shared/components/footer/footer.component.ts new file mode 100644 index 0000000..02f39ae --- /dev/null +++ b/frontend/src/app/shared/components/footer/footer.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-footer', + standalone: true, + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.css'] +}) +export class FooterComponent {} \ No newline at end of file diff --git a/frontend/src/app/shared/components/navbar/navbar.component.css b/frontend/src/app/shared/components/navbar/navbar.component.css new file mode 100644 index 0000000..41adc5b --- /dev/null +++ b/frontend/src/app/shared/components/navbar/navbar.component.css @@ -0,0 +1,147 @@ +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--card); + position: sticky; + top: 0; + z-index: 1000; + transition: box-shadow 0.3s ease, background 0.3s ease; +} + +/* Al hacer scroll */ +.navbar.scrolled { + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +/* LOGO */ +.logo { + font-weight: bold; + font-size: 1.2rem; +} + +/* MENU */ +.menu { + display: flex; + gap: 1.5rem; +} + +.menu a { + text-decoration: none; + color: var(--text); + font-weight: 500; + transition: 0.2s; +} + +.menu a:hover { + opacity: 0.7; +} + +.user { + display: flex; + align-items: center; + gap: 10px; +} + +.username { + font-size: 0.9rem; +} + +/* BOTONES */ +button { + padding: 6px 10px; + border-radius: 6px; + border: none; + background: var(--primary); + color: white; + cursor: pointer; + transition: 0.2s; +} + +button:hover { + opacity: 0.75; +} + +.theme-btn { + background: transparent; + border: 1px solid var(--text); + color: var(--text); +} + +/* DROPDOWN */ +.dropdown { + position: relative; +} + +.dropdown-toggle { + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 8px; +} + +.dropdown-menu { + position: absolute; + right: 0; + top: 45px; + background: var(--card); + border-radius: 10px; + box-shadow: 0 10px 25px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; + min-width: 180px; +} + +.dropdown-menu a, +.dropdown-menu button { + padding: 10px; + font-size: 14px; + text-align: left; + background: none; + border: none; + color: var(--text); + cursor: pointer; + text-decoration: none; +} + +.dropdown-menu a:hover, +.dropdown-menu button:hover { + background: rgba(0,0,0,0.1); +} + +/* AVATAR */ +.avatar { + width: 35px; + height: 35px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 0.9rem; +} + + +/* Mobile */ +@media (max-width: 768px) { + + .navbar { + flex-wrap: wrap; + gap: 10px; + } + + .menu { + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .user { + width: 100%; + justify-content: center; + } + +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/navbar/navbar.component.html b/frontend/src/app/shared/components/navbar/navbar.component.html new file mode 100644 index 0000000..08724df --- /dev/null +++ b/frontend/src/app/shared/components/navbar/navbar.component.html @@ -0,0 +1,38 @@ + \ No newline at end of file diff --git a/frontend/src/app/shared/components/navbar/navbar.component.spec.ts b/frontend/src/app/shared/components/navbar/navbar.component.spec.ts new file mode 100644 index 0000000..40dd916 --- /dev/null +++ b/frontend/src/app/shared/components/navbar/navbar.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NavbarComponent } from './navbar.component'; +import { AuthService } from '../../../core/services/auth.service'; +import { TokenService } from '../../../core/services/token.service'; +import { ThemeService } from '../../../core/services/theme.service'; +import { Router, provideRouter } from '@angular/router'; +import { By } from '@angular/platform-browser'; + +describe('NavbarComponent', () => { + let component: NavbarComponent; + let fixture: ComponentFixture; + + const authServiceMock = { + logout: jasmine.createSpy('logout') + }; + + const tokenServiceMock = { + getUsername: jasmine.createSpy('getUsername').and.returnValue('Alex') + }; + + const themeServiceMock = { + toggleTheme: jasmine.createSpy('toggleTheme'), + isDark: jasmine.createSpy('isDark').and.returnValue(false) + }; + + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + + imports: [NavbarComponent], + providers: [ + provideRouter([]), + { provide: AuthService, useValue: authServiceMock }, + { provide: TokenService, useValue: tokenServiceMock }, + { provide: ThemeService, useValue: themeServiceMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NavbarComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + + spyOn(router, 'navigate'); + + fixture.detectChanges(); + }); + + it('debería crear el componente', () => { + expect(component).toBeTruthy(); + }); + + it('debería alternar dropdownOpen al llamar a toggleDropdown()', () => { + expect(component.dropdownOpen).toBeFalse(); + component.toggleDropdown(); + expect(component.dropdownOpen).toBeTrue(); + component.toggleDropdown(); + expect(component.dropdownOpen).toBeFalse(); + }); + + it('debería llamar a theme.toggleTheme() al ejecutar toggleTheme()', () => { + component.toggleTheme(); + expect(themeServiceMock.toggleTheme).toHaveBeenCalled(); + }); + + it('debería cerrar sesión y navegar al login', () => { + component.logout(); + expect(authServiceMock.logout).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('debería detectar el scroll de la ventana', () => { + + spyOnProperty(window, 'scrollY', 'get').and.returnValue(50); + window.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + + expect(component.scrolled).toBeTrue(); + }); + + it('debería mostrar el menú desplegable solo cuando dropdownOpen es true', () => { + + let menu = fixture.debugElement.query(By.css('.dropdown-menu')); + expect(menu).toBeNull(); + + component.dropdownOpen = true; + fixture.detectChanges(); + + menu = fixture.debugElement.query(By.css('.dropdown-menu')); + expect(menu).not.toBeNull(); + }); + + it('debería generar una letra de avatar válida incluso si el nombre es minúscula', () => { + tokenServiceMock.getUsername.and.returnValue('paco'); + component.ngOnInit(); + expect(component.avatarLetter).toBe('P'); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/shared/components/navbar/navbar.component.ts b/frontend/src/app/shared/components/navbar/navbar.component.ts new file mode 100644 index 0000000..0284f83 --- /dev/null +++ b/frontend/src/app/shared/components/navbar/navbar.component.ts @@ -0,0 +1,80 @@ +import { Component, HostListener, OnInit } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../core/services/auth.service'; +import { TokenService } from '../../../core/services/token.service'; +import { ThemeService } from '../../../core/services/theme.service'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-navbar', + standalone: true, + imports: [RouterLink, NgIf], + templateUrl: './navbar.component.html', + styleUrls: ['./navbar.component.css'] +}) +export class NavbarComponent implements OnInit { + + username = ''; + menuOpen = false; + dropdownOpen = false; + scrolled = false; + + avatarLetter = ''; + avatarColor = ''; + + constructor( + private readonly auth: AuthService, + private readonly router: Router, + private readonly tokenService: TokenService, + public theme: ThemeService + ) {} + + ngOnInit() { + this.username = this.tokenService.getUsername() || 'Usuario'; + + this.avatarLetter = this.username.charAt(0).toUpperCase(); + this.avatarColor = this.getColorFromUsername(this.username); + } + + logout() { + this.auth.logout(); + this.router.navigate(['/login']); + } + + toggleTheme() { + this.theme.toggleTheme(); + } + + toggleMenu() { + this.menuOpen = !this.menuOpen; + } + + toggleDropdown() { + this.dropdownOpen = !this.dropdownOpen; + } + + @HostListener('window:scroll', []) + onWindowScroll() { + this.scrolled = window.scrollY > 10; + } + + getColorFromUsername(name: string): string { + const colors = [ + '#4f46e5', + '#16a34a', + '#dc2626', + '#ea580c', + '#0891b2', + '#7c3aed' + ]; + + let hash = 0; + for (const char of name) { + const codePoint = char.codePointAt(0) || 0; + hash = codePoint + ((hash << 5) - hash); + } + + return colors[Math.abs(hash) % colors.length]; + } + +} \ No newline at end of file diff --git a/frontend/src/app/usuarios/usuarios.component.spec.ts b/frontend/src/app/usuarios/usuarios.component.spec.ts deleted file mode 100644 index e958013..0000000 --- a/frontend/src/app/usuarios/usuarios.component.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UsuariosComponent } from './usuarios.component'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; - -describe('UsuariosComponent', () => { - let component: UsuariosComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UsuariosComponent], - providers: [ - provideHttpClient(), - provideHttpClientTesting() - ] - }) - .compileComponents(); - - fixture = TestBed.createComponent(UsuariosComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 211e09a..77f465f 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8080/api' + apiUrl: 'http://localhost:8080/api', + authUrl: 'http://localhost:8080/auth' }; \ No newline at end of file diff --git a/frontend/src/index.html b/frontend/src/index.html index 3af61ec..3138a04 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -2,7 +2,7 @@ - Frontend + GameSwap diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 90d4ee0..6481c85 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1 +1,37 @@ -/* You can add global styles to this file, and also import other style files */ +/* RESET */ +body { + margin: 0; + font-family: Arial, sans-serif; +} + +/* Claro */ +body.light { + --bg: #ffffff; + --text: #111; + --primary: #4f46e5; + --card: #f5f5f5; +} + +/* Oscuro */ +body.dark { + --bg: #111; + --text: #f5f5f5; + --primary: #6366f1; + --card: #1e1e1e; +} + +/* GLOBAL */ +body { + background: var(--bg); + color: var(--text); + transition: 0.3s; +} + +button { + cursor: pointer; + border: none; + padding: 10px; + border-radius: 8px; + background: var(--primary); + color: white; +} \ No newline at end of file