diff --git a/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchLinkFoundResponseDto.java b/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchLinkFoundResponseDto.java deleted file mode 100644 index 7974e62..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchLinkFoundResponseDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.decepticons.linkshortener.api.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; - -/** - * Data Transfer Object (DTO) representing the response when a requested link is not found. - * This object is typically returned in the response body of an API when a link-related - * operation fails due to the short link not existing in the system. - - * Example JSON: - * { - * "shortLink": "someShortLink", - * "message": "No such short link found in the system" - * } - */ -@AllArgsConstructor -@Getter -@Setter -public class NoSuchLinkFoundResponseDto { - - private String shortLink; - private String message; - -} diff --git a/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchUserFoundResponseDto.java b/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchUserFoundResponseDto.java deleted file mode 100644 index 16eee9f..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/dto/NoSuchUserFoundResponseDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.decepticons.linkshortener.api.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; - -/** - * Data Transfer Object (DTO) representing the response when a requested user is not found. - * This object is typically returned in the response body of an API when a user-related - * operation fails due to the user not existing in the system. - - * Example JSON: - * { - * "username": "Zakhar", - * "message": "No such user found in the system" - * } - */ -@AllArgsConstructor -@Getter -@Setter -public class NoSuchUserFoundResponseDto { - - private String username; - private String message; - -} diff --git a/src/main/java/org/decepticons/linkshortener/api/dto/ShortLinkOutOfDateResponseDto.java b/src/main/java/org/decepticons/linkshortener/api/dto/ShortLinkOutOfDateResponseDto.java deleted file mode 100644 index cbd17d0..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/dto/ShortLinkOutOfDateResponseDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.decepticons.linkshortener.api.dto; - -import java.time.Instant; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; - - -/** - * Data Transfer Object (DTO) representing the response when a requested link is out of date. - * This object is typically returned in the response body of an API when a link-related - * operation fails due to the short link being obsolete in the system. - */ -@AllArgsConstructor -@Getter -@Setter -public class ShortLinkOutOfDateResponseDto { - private String shortLink; - private String message; - private Instant expiredAt; -} diff --git a/src/main/java/org/decepticons/linkshortener/api/dto/UpdateLinkExpirationRequestDto.java b/src/main/java/org/decepticons/linkshortener/api/dto/UpdateLinkExpirationRequestDto.java new file mode 100644 index 0000000..d67cb51 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/dto/UpdateLinkExpirationRequestDto.java @@ -0,0 +1,24 @@ +package org.decepticons.linkshortener.api.dto; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; + + +/** + * Data Transfer Object (DTO) for updating the expiration date of a shortened link. + * This object is used to encapsulate the data required to update the expiration + * date of an existing short link in the system. + */ +@Getter +@Setter +@AllArgsConstructor +public class UpdateLinkExpirationRequestDto { + + @NonNull + private Instant newExpirationDate; + + +} diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/BaseException.java b/src/main/java/org/decepticons/linkshortener/api/exception/BaseException.java similarity index 92% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/BaseException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/BaseException.java index 864658f..277c444 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/BaseException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/BaseException.java @@ -1,4 +1,4 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; /** * Base class for custom exceptions in the application. diff --git a/src/main/java/org/decepticons/linkshortener/api/exception/CustomizedResponseEntityExceptionHandler.java b/src/main/java/org/decepticons/linkshortener/api/exception/CustomizedResponseEntityExceptionHandler.java deleted file mode 100644 index e18c4e0..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/exception/CustomizedResponseEntityExceptionHandler.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.decepticons.linkshortener.api.exception; - -import org.decepticons.linkshortener.api.dto.NoSuchLinkFoundResponseDto; -import org.decepticons.linkshortener.api.dto.NoSuchUserFoundResponseDto; -import org.decepticons.linkshortener.api.dto.ShortLinkOutOfDateResponseDto; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -/** - * Global exception handler for the application. - * Handles custom exceptions and transforms them into appropriate HTTP responses. - */ -@ControllerAdvice -public class CustomizedResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { - - /** - * Handles the case when a user is not found in the system. - * Returns a 404 Not Found response with a descriptive message. - */ - @ExceptionHandler(NoSuchUserFoundInTheSystemException.class) - public ResponseEntity handleNoSuchUserFoundInTheSystem( - NoSuchUserFoundInTheSystemException ex) { - - NoSuchUserFoundResponseDto response = new NoSuchUserFoundResponseDto( - ex.getUsername(), - "No such user found in the system" - ); - - return ResponseEntity.status(404).body(response); - } - - - /** - * Handles the case when a short link is not found in the system. - * Returns a 404 Not Found response with a descriptive message. - */ - @ExceptionHandler(NoSuchShortLinkFoundInTheSystemException.class) - public ResponseEntity handleNoSuchShortLinkFoundInTheSystem( - NoSuchShortLinkFoundInTheSystemException ex) { - - NoSuchLinkFoundResponseDto response = new NoSuchLinkFoundResponseDto( - ex.getShortLink(), - "No such short link found in the system" - ); - - return ResponseEntity.status(404).body(response); - } - - - /** - * Handles the case when a short link is out of date (expired). - * Returns a 410 Gone response with details about the expired link. - */ - @ExceptionHandler(ShortLinkIsOutOfDateException.class) - public ResponseEntity handleShortLinkIsOutOfDate( - ShortLinkIsOutOfDateException ex - ) { - ShortLinkOutOfDateResponseDto response = new ShortLinkOutOfDateResponseDto( - ex.getShortLink(), - "The short link is out of date", - ex.getExpiredAt() - ); - - return ResponseEntity.status(410).body(response); - } - - -} diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/ExpiredTokenException.java b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java similarity index 84% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/ExpiredTokenException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java index db24910..2b92426 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/ExpiredTokenException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java @@ -1,4 +1,6 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; + +import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when a JWT token is expired. diff --git a/src/main/java/org/decepticons/linkshortener/api/exception/InvalidExpirationDateException.java b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidExpirationDateException.java new file mode 100644 index 0000000..22244c8 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidExpirationDateException.java @@ -0,0 +1,30 @@ +package org.decepticons.linkshortener.api.exception; + +import java.time.Instant; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Exception thrown when an invalid expiration date is provided for a link. + * This exception indicates that the provided expiration date does not meet + * the required criteria (e.g., it may be in the past or too far in the future). + */ +@ResponseStatus(HttpStatus.BAD_REQUEST) +@Getter +public class InvalidExpirationDateException extends RuntimeException { + private final Instant invalidDate; + + /** + * Constructs a new InvalidExpirationDateException with the specified detail message + * and the invalid expiration date. + * + * @param message the detail message. + * @param invalidDate the invalid expiration date that caused this exception. + */ + public InvalidExpirationDateException(String message, Instant invalidDate) { + super(message); + this.invalidDate = invalidDate; + } + +} diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidPasswordException.java b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidPasswordException.java similarity index 75% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidPasswordException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/InvalidPasswordException.java index f3fe643..778c485 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidPasswordException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidPasswordException.java @@ -1,4 +1,6 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; + +import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when a password does not meet complexity requirements. diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidTokenException.java b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidTokenException.java similarity index 84% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidTokenException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/InvalidTokenException.java index 026aec4..e23a2e5 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/InvalidTokenException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/InvalidTokenException.java @@ -1,4 +1,6 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; + +import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when a JWT token is missing, malformed, or invalid. diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/UserAlreadyExistsException.java b/src/main/java/org/decepticons/linkshortener/api/exception/UserAlreadyExistsException.java similarity index 78% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/UserAlreadyExistsException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/UserAlreadyExistsException.java index e0bd017..f4b008e 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/UserAlreadyExistsException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/UserAlreadyExistsException.java @@ -1,4 +1,6 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; + +import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when trying to register a user with a username that already exists. diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/UserNotFoundException.java b/src/main/java/org/decepticons/linkshortener/api/exception/UserNotFoundException.java similarity index 76% rename from src/main/java/org/decepticons/linkshortener/api/exceptions/UserNotFoundException.java rename to src/main/java/org/decepticons/linkshortener/api/exception/UserNotFoundException.java index 53271a0..e804616 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/UserNotFoundException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/UserNotFoundException.java @@ -1,4 +1,6 @@ -package org.decepticons.linkshortener.api.exceptions; +package org.decepticons.linkshortener.api.exception; + +import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when a user is not found in the system. diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandler.java b/src/main/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandler.java deleted file mode 100644 index f4940d0..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandler.java +++ /dev/null @@ -1,167 +0,0 @@ -package org.decepticons.linkshortener.api.exceptions; - -import io.jsonwebtoken.JwtException; -import java.time.Instant; -import java.util.Map; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.AuthenticationException; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; - -/** - * Centralized exception handler for REST controllers. - * This class catches specific exceptions and maps them - * to appropriate HTTP status codes, ensuring a consistent and - * informative error response - * format. - */ -@ControllerAdvice -public class GlobalExceptionHandler { - - /** - * Constructs a standard error response body. - * - * @param status the HTTP status to return. - * @param error the short error description. - * @param message the detailed error message. - * @return a map representing the error response body. - */ - private ResponseEntity> buildErrorResponse( - final HttpStatus status, - final String error, - final String message) { - return ResponseEntity.status(status).body(Map.of( - "timestamp", Instant.now().toString(), - "status", status.value(), - "error", error, - "message", message - )); - } - - /** - * Handles all authentication-related exceptions, including bad credentials. - * This ensures that login failures and other authentication issues return a - * consistent 401 Unauthorized status. - * - * @param ex The AuthenticationException instance. - * @return A ResponseEntity with an UNAUTHORIZED status. - */ - @ExceptionHandler(AuthenticationException.class) - public ResponseEntity> handleAuthenticationException( - final AuthenticationException ex - ) { - return buildErrorResponse( - HttpStatus.UNAUTHORIZED, - "Authentication Failed", - "Invalid username or password"); - } - - /** - * Handles exceptions related to JWT parsing and validation. - * This catches various JWT-specific issues that might not be a part of - * your custom exceptions, such as signature validation - * failures or malformed tokens. - * - * @param ex The JwtException instance. - * @return A ResponseEntity with an UNAUTHORIZED status. - */ - @ExceptionHandler(JwtException.class) - public ResponseEntity> handleJwtException( - final JwtException ex - ) { - return buildErrorResponse( - HttpStatus.UNAUTHORIZED, - "Invalid Token", - ex.getMessage()); - } - - /** - * Handles exceptions when a user already exists. - * - * @param ex The UserAlreadyExistsException instance. - * @return A ResponseEntity with a CONFLICT status. - */ - @ExceptionHandler(UserAlreadyExistsException.class) - public ResponseEntity> handleUserExists( - final UserAlreadyExistsException ex) { - return buildErrorResponse( - HttpStatus.CONFLICT, - "User Already Exists", - ex.getMessage()); - } - - /** - * Handles exceptions when a user is not found. - * - * @param ex The UserNotFoundException instance. - * @return A ResponseEntity with a NOT_FOUND status. - */ - @ExceptionHandler(UserNotFoundException.class) - public ResponseEntity> handleUserNotFound( - final UserNotFoundException ex) { - return buildErrorResponse( - HttpStatus.NOT_FOUND, - "User Not Found", - ex.getMessage()); - } - - /** - * Handles exceptions when an invalid password is provided. - * - * @param ex The InvalidPasswordException instance. - * @return A ResponseEntity with a BAD_REQUEST status. - */ - @ExceptionHandler(InvalidPasswordException.class) - public ResponseEntity> handleInvalidPassword( - final InvalidPasswordException ex) { - return buildErrorResponse( - HttpStatus.BAD_REQUEST, - "Invalid Password", - ex.getMessage()); - } - - /** - * Handles exceptions for an invalid token. - * - * @param ex The InvalidTokenException instance. - * @return A ResponseEntity with an UNAUTHORIZED status. - */ - @ExceptionHandler(InvalidTokenException.class) - public ResponseEntity> handleInvalidToken( - final InvalidTokenException ex) { - return buildErrorResponse( - HttpStatus.UNAUTHORIZED, - "Invalid Token", - ex.getMessage()); - } - - /** - * Handles exceptions for an expired token. - * - * @param ex The ExpiredTokenException instance. - * @return A ResponseEntity with an UNAUTHORIZED status. - */ - @ExceptionHandler(ExpiredTokenException.class) - public ResponseEntity> handleExpiredToken( - final ExpiredTokenException ex) { - return buildErrorResponse(HttpStatus.UNAUTHORIZED, - "Expired Token", - ex.getMessage()); - } - - /** - * Handles generic exceptions. - * - * @param ex The generic Exception instance. - * @return A ResponseEntity with an INTERNAL_SERVER_ERROR status. - */ - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGeneric( - final Exception ex - ) { - return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, - "Server Error", - ex.getMessage()); - } -} diff --git a/src/main/java/org/decepticons/linkshortener/api/exceptions/package-info.java b/src/main/java/org/decepticons/linkshortener/api/exceptions/package-info.java deleted file mode 100644 index 6f3cb2c..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/exceptions/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Provides custom exception classes for the link shortener API. - */ -package org.decepticons.linkshortener.api.exceptions; diff --git a/src/main/java/org/decepticons/linkshortener/api/model/Link.java b/src/main/java/org/decepticons/linkshortener/api/model/Link.java index fabaf9b..4383a39 100644 --- a/src/main/java/org/decepticons/linkshortener/api/model/Link.java +++ b/src/main/java/org/decepticons/linkshortener/api/model/Link.java @@ -110,8 +110,12 @@ public void incrementClicks() { @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Link that)) return false; + if (this == o) { + return true; + } + if (!(o instanceof Link that)) { + return false; + } return Objects.equals(id, that.id); } diff --git a/src/main/java/org/decepticons/linkshortener/api/model/User.java b/src/main/java/org/decepticons/linkshortener/api/model/User.java index d69d236..b9446cc 100644 --- a/src/main/java/org/decepticons/linkshortener/api/model/User.java +++ b/src/main/java/org/decepticons/linkshortener/api/model/User.java @@ -41,11 +41,11 @@ *
  • {@code updatedAt} – Timestamp of last update.
  • * * - * @since 1.0 - * author Ruslan Lomaka + * @since 1.0 + * author Ruslan Lomaka */ + @Getter -@Setter @Entity @Table(name = "users", uniqueConstraints = @UniqueConstraint(name = "uk_users_username", columnNames = "username")) diff --git a/src/main/java/org/decepticons/linkshortener/api/repository/LinkRepository.java b/src/main/java/org/decepticons/linkshortener/api/repository/LinkRepository.java index 62cc634..aed11f6 100644 --- a/src/main/java/org/decepticons/linkshortener/api/repository/LinkRepository.java +++ b/src/main/java/org/decepticons/linkshortener/api/repository/LinkRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; /** @@ -51,4 +54,21 @@ public interface LinkRepository extends JpaRepository { * @return a {@link Page} of {@link Link} objects belonging to the user with the specified status */ Page findAllByOwnerIdAndStatus(UUID ownerId, LinkStatus status, Pageable pageable); + + + /** + * Increments the click count and updates the last accessed timestamp for a link + * identified by its short code using a native SQL query. + * + * @param code the short code of the link to update + * @return the number of rows affected (should be 1 if the link exists, 0 otherwise) + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE links + SET clicks = clicks + 1, last_accessed_at = now() + WHERE code = :code + """, nativeQuery = true) + int incrementClicksByCodeNative(@Param("code") String code); + } \ No newline at end of file diff --git a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java index b2db1c1..34bb375 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java @@ -57,10 +57,11 @@ public SecurityFilterChain securityFilterChain( "/api/v1/auth/login", "/api/v1/auth/refresh", -// Redirect endpoint + // Redirect endpoint "/api/links/**", -// Documentation & health endpoints + // Documentation & health endpoints + "/api/v1/cache", "/health", "/h2-console/**", "/swagger-ui.html", diff --git a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java index 75c95da..5dddb72 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java @@ -5,7 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import org.decepticons.linkshortener.api.exceptions.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java index 40548bc..4fae884 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java @@ -3,7 +3,7 @@ import java.util.Collections; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.decepticons.linkshortener.api.exceptions.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; import org.decepticons.linkshortener.api.model.Role; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.model.UserStatus; diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java index 62005e5..fb448d9 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java @@ -2,9 +2,9 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.decepticons.linkshortener.api.exceptions.InvalidPasswordException; -import org.decepticons.linkshortener.api.exceptions.UserAlreadyExistsException; -import org.decepticons.linkshortener.api.exceptions.UserNotFoundException; +import org.decepticons.linkshortener.api.exception.InvalidPasswordException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.UserRepository; import org.decepticons.linkshortener.api.security.service.UserAuthService; diff --git a/src/main/java/org/decepticons/linkshortener/api/service/CacheEvictService.java b/src/main/java/org/decepticons/linkshortener/api/service/CacheEvictService.java deleted file mode 100644 index 9ffd6d8..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/service/CacheEvictService.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.decepticons.linkshortener.api.service; - -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.stereotype.Service; - -/** - * Service for evicting cache entries related to short links. - */ -@Service -public class CacheEvictService { - /** - * Evicts the cache entry for the given short link code. - * - * @param code the short link code whose cache entry should be evicted - */ - @CacheEvict(value = "shortLinksCache", key = "#code") - public void evictLink(String code) { // it's indeed should be empty, don't delete it :) - } -} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/CacheInspectionService.java b/src/main/java/org/decepticons/linkshortener/api/service/CacheInspectionService.java deleted file mode 100644 index 6b29c93..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/service/CacheInspectionService.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.decepticons.linkshortener.api.service; - - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.cache.Cache; -import org.springframework.cache.CacheManager; -import org.springframework.stereotype.Service; - -/** - * Service for inspecting and printing cache contents. - */ -@Service -public class CacheInspectionService { - - - private final CacheManager cacheManager; - private final Logger logger = LoggerFactory.getLogger(CacheInspectionService.class); - - /** - * Constructs a CacheInspectionService with the given CacheManager. - * - * @param cacheManager the CacheManager to use for accessing caches - */ - - public CacheInspectionService(CacheManager cacheManager) { - this.cacheManager = cacheManager; - } - - /** - * Prints the contents of the specified cache to the console. - * - * @param cacheName the name of the cache to inspect - */ - public void printCache(String cacheName) { - Cache cache = cacheManager.getCache(cacheName); - if (cache != null) { - logger.debug("Cache Name: {}", cacheName); - logger.debug("Cache Content: {}", cache.getNativeCache()); - } else { - logger.debug("Cache with name {} not found", cacheName); - } - } - -} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/LinkService.java b/src/main/java/org/decepticons/linkshortener/api/service/LinkService.java index ca97fc4..b80641c 100644 --- a/src/main/java/org/decepticons/linkshortener/api/service/LinkService.java +++ b/src/main/java/org/decepticons/linkshortener/api/service/LinkService.java @@ -1,272 +1,91 @@ package org.decepticons.linkshortener.api.service; import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Random; import java.util.UUID; - import org.decepticons.linkshortener.api.dto.LinkResponseDto; import org.decepticons.linkshortener.api.dto.UrlRequestDto; -import org.decepticons.linkshortener.api.exception.NoSuchShortLinkFoundInTheSystemException; -import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; -import org.decepticons.linkshortener.api.model.Link; -import org.decepticons.linkshortener.api.model.LinkStatus; -import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.repository.LinkRepository; -import org.decepticons.linkshortener.api.repository.UserRepository; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.annotation.CachePut; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; + /** - * Application service responsible for creating and maintaining {@link Link} entities. - * Provides operations for link creation, click tracking, simple DTO mapping, - * and basic liveness checks (active + not expired). - *

    + * Service interface for managing links in the link shortener application. + * Provides methods for creating, retrieving, updating, and deleting links, + * as well as tracking link clicks and validating link status. */ - - -@Service -public class LinkService { - - @Value("${link.expiration-days}") - private long linkExpirationDays; - - private final LinkRepository linkRepository; - private final UserRepository userRepository; - private final CacheEvictService cacheEvictService; - private final Random random = new Random(); - +public interface LinkService { /** - * Creates a new {@code LinkService}. + * Creates and persists a new shortened link based on the provided original URL. * - * @param linkRepository repository used to persist and load {@link Link} entities + * @param originalUrl the original URL to be shortened + * @return a LinkResponseDto containing details of the created shortened link */ - - public LinkService(LinkRepository linkRepository, - UserRepository userRepository, - CacheEvictService cacheEvictService) { - this.linkRepository = linkRepository; - this.userRepository = userRepository; - this.cacheEvictService = cacheEvictService; - } - + LinkResponseDto createLink(UrlRequestDto originalUrl); /** - * Creates and persists a new {@link Link}. + * Increments the click count for the specified link. * - * @param originalUrl the original long URL to be shortened - * @param owner the user who owns the link (must be non-null and managed) - * @return a {@link LinkResponseDto} representing the newly created link + * @param link the LinkResponseDto representing the link to update + * @return the updated LinkResponseDto with incremented click count */ - @Transactional - public LinkResponseDto createLink(UrlRequestDto originalUrl, User owner) { - Link link = new Link(); - link.setOriginalUrl(originalUrl.getUrl()); - link.setOwner(owner); - - link.setCode(generateRandomCode()); - link.setExpiresAt(Instant.now().plus(linkExpirationDays, ChronoUnit.DAYS)); - link.setStatus(LinkStatus.ACTIVE); - - linkRepository.save(link); - - return mapToResponse(link); - } - + LinkResponseDto incrementClicks(LinkResponseDto link); /** - * Increments the click counter of the given link and persists the change. - * This should be invoked whenever the shortened URL is accessed. - * Internally, the entity updates its {@code lastAccessedAt} timestamp. - *

    + * Retrieves a link by its unique short code. * - * @param link the link whose click counter should be incremented + * @param code the unique short code of the link + * @return the LinkResponseDto representing the retrieved link */ - @Transactional - @CachePut(value = "shortLinksCache", key = "#link.code") - public LinkResponseDto incrementClicks(LinkResponseDto link) { - Link linkByCode = linkRepository.findByCode(link.code()) - .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( - "No such short link found in the system: " + link.code(), - link.code() - )); - linkByCode.incrementClicks(); - linkRepository.save(linkByCode); - - return mapToResponse(linkByCode); - } - + LinkResponseDto getLinkByCode(String code); /** - * Maps a {@link Link} JPA entity to a transport-friendly {@link LinkResponseDto}. + * Deactivates the specified link, preventing further access. * - * @param link the entity to map - * @return a response DTO with the most relevant fields + * @param link the LinkResponseDto representing the link to deactivate + * @return the updated LinkResponseDto with deactivated status */ - public LinkResponseDto mapToResponse(Link link) { - return new LinkResponseDto( - - link.getId(), - link.getCode(), - link.getOriginalUrl(), - link.getCreatedAt(), - link.getExpiresAt(), - link.getClicks(), - link.getStatus().name(), - link.getOwner().getId() - ); - } - + LinkResponseDto deactivateLink(LinkResponseDto link); /** - * Generates a pseudo-random short code of fixed length . + * Validates whether the specified link is active and not expired. * - * @return a new short code (e.g., {@code "aZ3fQ1"}) + * @param link the LinkResponseDto representing the link to validate + * @return true if the link is valid (active and not expired), false otherwise */ - private String generateRandomCode() { - String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 6; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); - } - return sb.toString(); - } - + boolean validateLink(LinkResponseDto link); /** - * Retrieves a {@link Link} entity by its short code. + * Retrieves a paginated list of all links created by the currently authenticated user. * - * @param code short link code. - * @return Optional + * @param page the page number to retrieve + * @param size the number of links per page + * @return a Page of LinkResponseDto representing the user's links */ - @Cacheable(value = "shortLinksCache", key = "#code") - public LinkResponseDto getLinkByCode(String code) { - Link link = linkRepository.findByCode(code) - .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( - "No such short link found in the system: " + code, - code - )); - - return mapToResponse(link); - - } - + Page getAllMyLinks(int page, int size); /** - * Deactivates a link by setting its status to INACTIVE. + * Retrieves a paginated list of all active links created by the currently authenticated user. * - * @param link the link to deactivate - * @return the updated {@link LinkResponseDto} with status set to INACTIVE + * @param page the page number to retrieve + * @param size the number of links per page + * @return a Page of LinkResponseDto representing the user's active links */ - @CachePut(value = "shortLinksCache", key = "#link.code") - public LinkResponseDto deactivateLink(LinkResponseDto link) { - Link linkByCode = linkRepository.findByCode(link.code()) - .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( - "No such short link found in the system: " + link.code(), - link.code() - )); - linkByCode.setStatus(LinkStatus.INACTIVE); - linkRepository.save(linkByCode); - return mapToResponse(linkByCode); - } - - - /** - * Validates if a link is active and not expired. - * - * @param link the link to validate - * @return {@code true} if the link is active and not expired; {@code false} otherwise - */ - public boolean validateLink(LinkResponseDto link) { - return link.status().equalsIgnoreCase(LinkStatus.ACTIVE.toString()) - && (link.expiresAt() == null || link.expiresAt().isAfter(Instant.now())); - } - - - /** - * Retrieves all links of the currently authenticated user with pagination. - * - * @param page the page number to retrieve (0-based) - * @param size the number of records per page - * @return a {@link Page} of {@link LinkResponseDto} objects representing all user's links - */ - public Page getAllMyLinks(int page, int size) { - UUID userId = getCurrentUserId(); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - return linkRepository.findAllByOwnerId(userId, pageable) - .map(this::mapToResponse); - } + Page getAllMyActiveLinks(int page, int size); /** - * Retrieves all active links of the currently authenticated user with pagination. + * Deletes a link by its unique identifier. * - * @param page the page number to retrieve (0-based) - * @param size the number of records per page - * @return a {@link Page} of {@link LinkResponseDto} objects representing active user's links + * @param linkId the UUID of the link to delete + * @return a confirmation message indicating the result of the deletion */ - public Page getAllMyActiveLinks(int page, int size) { - UUID userId = getCurrentUserId(); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - return linkRepository.findAllByOwnerIdAndStatus(userId, LinkStatus.ACTIVE, pageable) - .map(this::mapToResponse); - } + String deleteLink(UUID linkId); /** - * Deletes a link from the database if it belongs to the currently authenticated user. + * Updates the expiration date of a link identified by its short code. * - * @param linkId the unique identifier of the link to delete + * @param code the unique short code of the link to update + * @param newExpirationDate the new expiration date to set + * @return the updated LinkResponseDto with the new expiration date */ - @Transactional - public void deleteLink(UUID linkId) { - UUID currentUserId = getCurrentUserId(); - - getCurrentUserId(); - Link link = linkRepository.findById(linkId) - .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( - "No such short link found in the system", linkId.toString() - )); - - - if (!link.getOwner().getId().equals(currentUserId)) { - throw new AccessDeniedException("You are not allowed to delete this link"); - } - - linkRepository.delete(link); - cacheEvictService.evictLink(link.getCode()); - } - - /** - * Retrieves the UUID of the currently authenticated user from the security context. - * - * @return the UUID of the authenticated user - */ - private UUID getCurrentUserId() { - String username = SecurityContextHolder.getContext().getAuthentication().getName(); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( - "No such user found in the system: " + username, - username - )); - return user.getId(); - } - - public User getCurrentUser() { - String username = SecurityContextHolder.getContext().getAuthentication().getName(); - return userRepository.findByUsername(username) - .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( - "No such user found in the system: " + username, - username - )); - } + LinkResponseDto updateLinkExpiration(String code, Instant newExpirationDate); } - diff --git a/src/main/java/org/decepticons/linkshortener/api/service/UserService.java b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java new file mode 100644 index 0000000..af05c4c --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java @@ -0,0 +1,25 @@ +package org.decepticons.linkshortener.api.service; + +import java.util.UUID; +import org.decepticons.linkshortener.api.model.User; + + +/** + * Service interface for retrieving information about the currently authenticated user. + */ +public interface UserService { + + /** + * Retrieves the currently authenticated user. + * + * @return the User object representing the current user + */ + User getCurrentUser(); + + /** + * Retrieves the unique identifier (UUID) of the currently authenticated user. + * + * @return the UUID of the current user + */ + UUID getCurrentUserId(); +} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/LinkServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/LinkServiceImpl.java new file mode 100644 index 0000000..7d200e9 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/LinkServiceImpl.java @@ -0,0 +1,295 @@ +package org.decepticons.linkshortener.api.service.impl; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Random; +import java.util.UUID; +import org.decepticons.linkshortener.api.dto.LinkResponseDto; +import org.decepticons.linkshortener.api.dto.UrlRequestDto; +import org.decepticons.linkshortener.api.exception.InvalidExpirationDateException; +import org.decepticons.linkshortener.api.exception.NoSuchShortLinkFoundInTheSystemException; +import org.decepticons.linkshortener.api.model.Link; +import org.decepticons.linkshortener.api.model.LinkStatus; +import org.decepticons.linkshortener.api.repository.LinkRepository; +import org.decepticons.linkshortener.api.service.LinkService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Application service responsible for creating and maintaining {@link Link} entities. + * Provides operations for link creation, click tracking, simple DTO mapping, + * and basic liveness checks (active + not expired). + *

    + */ + + +@Service +public class LinkServiceImpl implements LinkService { + + @Value("${link.expiration-days}") + private long linkExpirationDays; + + private final LinkRepository linkRepository; + private final UserServiceImpl userServiceImpl; + private final Random random = new Random(); + + + /** + * Creates a new {@code LinkService}. + * + * @param linkRepository repository used to persist and load {@link Link} entities + */ + + public LinkServiceImpl(LinkRepository linkRepository, + UserServiceImpl userServiceImpl) { + this.linkRepository = linkRepository; + this.userServiceImpl = userServiceImpl; + } + + + /** + * Creates and persists a new {@link Link}. + * + * @param originalUrl the original long URL to be shortened + * @return a {@link LinkResponseDto} representing the newly created link + */ + @Override + @Transactional + public LinkResponseDto createLink(UrlRequestDto originalUrl) { + Link link = new Link(); + link.setOriginalUrl(originalUrl.getUrl()); + link.setOwner(userServiceImpl.getCurrentUser()); + + link.setCode(generateRandomCode()); + link.setExpiresAt(Instant.now().plus(linkExpirationDays, ChronoUnit.DAYS)); + link.setStatus(LinkStatus.ACTIVE); + + Link saved = linkRepository.save(link); + + return mapToResponse(saved); + } + + + /** + * Increments the click counter of the given link and persists the change. + * This should be invoked whenever the shortened URL is accessed. + * Internally, the entity updates its {@code lastAccessedAt} timestamp. + *

    + * + * @param link the link whose click counter should be incremented + */ + @Override + @Transactional + public LinkResponseDto incrementClicks(LinkResponseDto link) { + + int affectedRows = linkRepository.incrementClicksByCodeNative(link.code()); + + if (affectedRows == 0) { + throw new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system: " + link.code(), + link.code() + ); + } + Link updatedLink = linkRepository.findByCode(link.code()) + .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system: " + link.code(), + link.code() + )); + + return mapToResponse(updatedLink); + } + + + /** + * Maps a {@link Link} JPA entity to a transport-friendly {@link LinkResponseDto}. + * + * @param link the entity to map + * @return a response DTO with the most relevant fields + */ + public LinkResponseDto mapToResponse(Link link) { + return new LinkResponseDto( + + link.getId(), + link.getCode(), + link.getOriginalUrl(), + link.getCreatedAt(), + link.getExpiresAt(), + link.getClicks(), + link.getStatus().name(), + link.getOwner().getId() + ); + } + + + /** + * Generates a pseudo-random short code of fixed length . + * + * @return a new short code (e.g., {@code "aZ3fQ1"}) + */ + private String generateRandomCode() { + String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 6; i++) { + sb.append(chars.charAt(random.nextInt(chars.length()))); + } + return sb.toString(); + } + + + /** + * Retrieves a {@link Link} entity by its short code. + * + * @param code short link code. + * @return Optional + */ + @Override + @Cacheable(value = "shortLinksCache", key = "#code") + public LinkResponseDto getLinkByCode(String code) { + Link link = linkRepository.findByCode(code) + .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system: " + code, + code + )); + + return mapToResponse(link); + + } + + + /** + * Deactivates a link by setting its status to INACTIVE. + * + * @param link the link to deactivate + * @return the updated {@link LinkResponseDto} with status set to INACTIVE + */ + + @Override + @CacheEvict(value = "shortLinksCache", key = "#result.code") + public LinkResponseDto deactivateLink(LinkResponseDto link) { + Link linkByCode = linkRepository.findByCode(link.code()) + .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system: " + link.code(), + link.code() + )); + linkByCode.setStatus(LinkStatus.INACTIVE); + Link saved = linkRepository.save(linkByCode); + return mapToResponse(saved); + } + + + /** + * Validates if a link is active and not expired. + * + * @param link the link to validate + * @return {@code true} if the link is active and not expired; {@code false} otherwise + */ + @Override + public boolean validateLink(LinkResponseDto link) { + return link.status().equalsIgnoreCase(LinkStatus.ACTIVE.toString()) + && (link.expiresAt() == null || link.expiresAt().isAfter(Instant.now())); + } + + + /** + * Retrieves all links of the currently authenticated user with pagination. + * + * @param page the page number to retrieve (0-based) + * @param size the number of records per page + * @return a {@link Page} of {@link LinkResponseDto} objects representing all user's links + */ + @Override + public Page getAllMyLinks(int page, int size) { + UUID userId = userServiceImpl.getCurrentUserId(); + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + return linkRepository.findAllByOwnerId(userId, pageable) + .map(this::mapToResponse); + } + + /** + * Retrieves all active links of the currently authenticated user with pagination. + * + * @param page the page number to retrieve (0-based) + * @param size the number of records per page + * @return a {@link Page} of {@link LinkResponseDto} objects representing active user's links + */ + @Override + public Page getAllMyActiveLinks(int page, int size) { + UUID userId = userServiceImpl.getCurrentUserId(); + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + return linkRepository.findAllByOwnerIdAndStatus(userId, LinkStatus.ACTIVE, pageable) + .map(this::mapToResponse); + } + + /** + * Deletes a link from the database if it belongs to the currently authenticated user. + * + * @param linkId the unique identifier of the link to delete + */ + @Transactional + @Override + @CacheEvict(value = "shortLinksCache", key = "#result") + public String deleteLink(UUID linkId) { + UUID currentUserId = userServiceImpl.getCurrentUserId(); + + Link link = linkRepository.findById(linkId) + .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system", linkId.toString() + )); + + + if (!link.getOwner().getId().equals(currentUserId)) { + throw new AccessDeniedException("You are not allowed to delete this link"); + } + + linkRepository.delete(link); + + return link.getCode(); + } + + /** + * Retrieves the UUID of the currently authenticated user from the security context. + * + * @return the UUID of the authenticated user + */ + + + + + @CachePut(value = "shortLinksCache", key = "#code") + @Override + public LinkResponseDto updateLinkExpiration(String code, Instant newExpirationDate) { + Link link = linkRepository.findByCode(code) + .orElseThrow(() -> new NoSuchShortLinkFoundInTheSystemException( + "No such short link found in the system: " + code, + code + )); + + if (!link.getOwner().getId().equals(userServiceImpl.getCurrentUserId())) { + throw new AccessDeniedException("You are not allowed to update this link"); + } + + if (newExpirationDate.isBefore(Instant.now())) { + throw new InvalidExpirationDateException( + "Expiration date must be in the future", + newExpirationDate + ); + } + + link.setExpiresAt(newExpirationDate); + Link saved = linkRepository.save(link); + + return mapToResponse(saved); + } + + +} + diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..e2ea2f1 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java @@ -0,0 +1,53 @@ +package org.decepticons.linkshortener.api.service.impl; + +import java.util.UUID; +import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; +import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.repository.UserRepository; +import org.decepticons.linkshortener.api.service.UserService; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + + +/** + * Service implementation for retrieving information about the currently authenticated user. + */ +@Service +public class UserServiceImpl implements UserService { + + public UserRepository userRepository; + + /** + * Constructs a new UserServiceImpl with the given UserRepository. + * + * @param userRepository the repository used to access user data + */ + public UserServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UUID getCurrentUserId() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( + "No such user found in the system: " + username, + username + )); + return user.getId(); + + } + + @Override + public User getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository.findByUsername(username) + .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( + "No such user found in the system: " + username, + username + )); + } + + + +} diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/CacheController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/CacheController.java deleted file mode 100644 index cf54335..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/CacheController.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.decepticons.linkshortener.api.v1.controller; - -import org.decepticons.linkshortener.api.service.CacheInspectionService; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * REST controller for cache inspection. - * Provides an endpoint to inspect the contents of the short links cache. - * This is primarily for debugging and monitoring purposes. - * Note: In a production environment, access to this endpoint should be - * restricted to authorized personnel only, as it may expose sensitive data. - */ -@RestController -@RequestMapping("/api/v1/cache") -public class CacheController { - - private final CacheInspectionService cacheInspectionService; - - /** - * Constructs a new {@link CacheController} with the given dependencies. - * - * @param cacheInspectionService the service responsible for cache inspection - */ - public CacheController(CacheInspectionService cacheInspectionService) { - this.cacheInspectionService = cacheInspectionService; - } - - /** - * Endpoint to inspect the contents of the short links cache. - * This is primarily for debugging and monitoring purposes. - * Note: In a production environment, access to this endpoint should be - * restricted to authorized personnel only, as it may expose sensitive data. - */ - @GetMapping - public void inspectCache() { - cacheInspectionService.printCache("shortLinksCache"); - } -} diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudController.java index f59240a..b040eda 100644 --- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudController.java +++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudController.java @@ -1,15 +1,18 @@ package org.decepticons.linkshortener.api.v1.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.UUID; import org.decepticons.linkshortener.api.dto.LinkResponseDto; +import org.decepticons.linkshortener.api.dto.UpdateLinkExpirationRequestDto; import org.decepticons.linkshortener.api.dto.UrlRequestDto; -import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.service.LinkService; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,12 +21,14 @@ import org.springframework.web.bind.annotation.RestController; + + /** * REST controller for managing short links. - * Provides endpoints for creating, retrieving, and deleting short links. * All operations are performed in the context of the currently authenticated user. */ +@Tag(name = "Link Management", description = "Operations for managing short links") @RestController @RequestMapping("/api/v1/links") public class LinkCrudController { @@ -34,12 +39,12 @@ public class LinkCrudController { /** * Constructs a new {@link LinkCrudController} with the given dependencies. * - * @param linkService the service responsible for link business logic - + * @param linkService the service responsible for link business logic */ public LinkCrudController(LinkService linkService) { this.linkService = linkService; + } /** @@ -49,10 +54,11 @@ public LinkCrudController(LinkService linkService) { * @return DTO with information about the created short URL */ @PostMapping + @Operation(summary = "Create a short URL for the current user") public ResponseEntity createLink(@Valid @RequestBody UrlRequestDto originalUrl) { - User user = linkService.getCurrentUser(); - LinkResponseDto link = linkService.createLink(originalUrl, user); + + LinkResponseDto link = linkService.createLink(originalUrl); return ResponseEntity.status(201).body(link); } @@ -65,6 +71,7 @@ public ResponseEntity createLink(@Valid @RequestBody UrlRequest * @return page of LinkResponseDto */ @GetMapping("/my_all_links") + @Operation(summary = "Get all links (active and inactive) for the current user") public ResponseEntity> getAllMyLinks( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size @@ -80,6 +87,7 @@ public ResponseEntity> getAllMyLinks( * @return page of LinkResponseDto */ @GetMapping("/my_all_active_links") + @Operation(summary = "Get all active links for the current user") public ResponseEntity> getAllMyActiveLinks( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size @@ -94,8 +102,30 @@ public ResponseEntity> getAllMyActiveLinks( * @return HTTP 204 No Content if deletion was successful */ @DeleteMapping("/delete/{id}") + @Operation(summary = "Delete a specific link of the current user by its ID") public ResponseEntity deleteLink(@PathVariable UUID id) { linkService.deleteLink(id); return ResponseEntity.noContent().build(); } + + + + /** + * Updates the expiration date of a specific link identified by its code. + * + * @param newExpirationDate DTO containing the new expiration date + * @param code the short URL code + * @return DTO with information about the updated link + */ + @PatchMapping("/{code}") + @Operation(summary = "Update the expiration date of a specific link by its code") + public ResponseEntity updateLinkExpiration( + @Valid @RequestBody UpdateLinkExpirationRequestDto newExpirationDate, + @PathVariable String code) { + + return ResponseEntity + .ok(linkService.updateLinkExpiration(code, newExpirationDate.getNewExpirationDate())); + } + + } diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java new file mode 100644 index 0000000..0c61b9b --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java @@ -0,0 +1,228 @@ +package org.decepticons.linkshortener.api.v1.controller.unversioned; + +import java.time.Instant; +import java.util.Map; +import org.decepticons.linkshortener.api.exception.ExpiredTokenException; +import org.decepticons.linkshortener.api.exception.InvalidPasswordException; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.NoSuchShortLinkFoundInTheSystemException; +import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; +import org.decepticons.linkshortener.api.exception.ShortLinkIsOutOfDateException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + + + +/** + * Global exception handler for managing application-wide exceptions. + */ +@ControllerAdvice +public class GlobalExceptionHandlerController { + + + /** + * Builds a standardized error response. + * + * @param status The HTTP status to be returned. + * @param error A brief description of the error. + * @param message A detailed message about the error. + * @param details Additional details about the error. + * @return A ResponseEntity containing the error details. + */ + private ResponseEntity> buildErrorResponse( + final HttpStatus status, + final String error, + final String message, + final Object details) { + return ResponseEntity.status(status).body(Map.of( + "timestamp", Instant.now().toString(), + "status", status.value(), + "error", error, + "message", message, + "details", details + )); + } + + /** + * Builds a standardized error response. + * + * @param status The HTTP status to be returned. + * @param error A brief description of the error. + * @param message A detailed message about the error. + * @return A ResponseEntity containing the error details. + */ + + + private ResponseEntity> buildErrorResponseSecurity( + final HttpStatus status, + final String error, + final String message) { + return ResponseEntity.status(status).body(Map.of( + "timestamp", Instant.now().toString(), + "status", status.value(), + "error", error, + "message", message + )); + } + + + /** + * Handles exceptions when a user already exists. + * + * @param ex The UserAlreadyExistsException instance. + * @return A ResponseEntity with a CONFLICT status. + */ + @ExceptionHandler(UserAlreadyExistsException.class) + public ResponseEntity> handleUserExists( + final UserAlreadyExistsException ex) { + return buildErrorResponseSecurity( + HttpStatus.CONFLICT, + "User Already Exists", + ex.getMessage()); + } + + /** + * Handles exceptions when a user is not found. + * + * @param ex The UserNotFoundException instance. + * @return A ResponseEntity with a NOT_FOUND status. + */ + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFound( + final UserNotFoundException ex) { + return buildErrorResponseSecurity( + HttpStatus.NOT_FOUND, + "User Not Found", + ex.getMessage()); + } + + /** + * Handles exceptions when an invalid password is provided. + * + * @param ex The InvalidPasswordException instance. + * @return A ResponseEntity with a BAD_REQUEST status. + */ + @ExceptionHandler(InvalidPasswordException.class) + public ResponseEntity> handleInvalidPassword( + final InvalidPasswordException ex) { + return buildErrorResponseSecurity( + HttpStatus.BAD_REQUEST, + "Invalid Password", + ex.getMessage()); + } + + /** + * Handles exceptions for an invalid token. + * + * @param ex The InvalidTokenException instance. + * @return A ResponseEntity with an UNAUTHORIZED status. + */ + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity> handleInvalidToken( + final InvalidTokenException ex) { + return buildErrorResponseSecurity( + HttpStatus.UNAUTHORIZED, + "Invalid Token", + ex.getMessage()); + } + + /** + * Handles exceptions for an expired token. + * + * @param ex The ExpiredTokenException instance. + * @return A ResponseEntity with an UNAUTHORIZED status. + */ + @ExceptionHandler(ExpiredTokenException.class) + public ResponseEntity> handleExpiredToken( + final ExpiredTokenException ex) { + return buildErrorResponseSecurity(HttpStatus.UNAUTHORIZED, + "Expired Token", + ex.getMessage()); + } + + /** + * Handles generic exceptions. + * + * @param ex The generic Exception instance. + * @return A ResponseEntity with an INTERNAL_SERVER_ERROR status. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(final Exception ex) { + return buildErrorResponseSecurity(HttpStatus.INTERNAL_SERVER_ERROR, + "Server Error", + ex.getMessage()); + } + + + /** + * Handles exceptions when no such user is found in the system. + * + * @param ex the exception instance + * + * @return a ResponseEntity with error details + */ + + @ExceptionHandler(NoSuchUserFoundInTheSystemException.class) + public ResponseEntity> handleNoSuchUser( + NoSuchUserFoundInTheSystemException ex) { + + return buildErrorResponse( + HttpStatus.NOT_FOUND, + "User Not Found", + "No such user in the system", + Map.of( + "username", ex.getUsername() + ) + ); + } + + /** + * Handles exceptions when no such short link is found in the system. + * + * @param ex Exception instance + * + * @return Response entity with error details + */ + + @ExceptionHandler(NoSuchShortLinkFoundInTheSystemException.class) + public ResponseEntity> handleNoSuchLink( + NoSuchShortLinkFoundInTheSystemException ex) { + + return buildErrorResponse( + HttpStatus.NOT_FOUND, + "Short Link Not Found", + "No such short link in the system", + Map.of( + "shortLink", ex.getShortLink() + ) + ); + } + + /** + * Handles exceptions when a short link is out of date. + * + * @param ex Exception instance + * + * @return Response entity with error details + */ + + @ExceptionHandler(ShortLinkIsOutOfDateException.class) + public ResponseEntity> handleShortLinkOutOfDate( + ShortLinkIsOutOfDateException ex) { + return buildErrorResponse( + HttpStatus.GONE, + "Short Link Expired", + "The short link is out of date", + Map.of( + "shortLink", ex.getShortLink(), + "expiredAt", ex.getExpiredAt() + ) + ); + } + + +} diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectController.java index 7fd9987..92f8247 100644 --- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectController.java +++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectController.java @@ -2,13 +2,11 @@ import jakarta.servlet.http.HttpServletResponse; - import java.io.IOException; - import org.decepticons.linkshortener.api.dto.LinkResponseDto; import org.decepticons.linkshortener.api.exception.ShortLinkIsOutOfDateException; import org.decepticons.linkshortener.api.model.LinkStatus; -import org.decepticons.linkshortener.api.service.LinkService; +import org.decepticons.linkshortener.api.service.impl.LinkServiceImpl; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,23 +15,22 @@ /** * REST controller for redirecting short links. - *

    * Provides an endpoint to redirect to the original URL based on the short code. */ @RestController @RequestMapping("/api/links") public class RedirectController { - private final LinkService linkService; + private final LinkServiceImpl linkServiceImpl; /** * Constructs a new {@link RedirectController} with the given dependencies. * - * @param linkService the service responsible for link business logic + * @param linkServiceImpl the service responsible for link business logic */ - public RedirectController(LinkService linkService) { - this.linkService = linkService; + public RedirectController(LinkServiceImpl linkServiceImpl) { + this.linkServiceImpl = linkServiceImpl; } @@ -46,13 +43,13 @@ public RedirectController(LinkService linkService) { public void redirect(@PathVariable String code, HttpServletResponse response) throws IOException { - LinkResponseDto linkByCode = linkService.getLinkByCode(code); + LinkResponseDto linkByCode = linkServiceImpl.getLinkByCode(code); - if (linkService.validateLink(linkByCode)) { - linkService.incrementClicks(linkByCode); + if (linkServiceImpl.validateLink(linkByCode)) { + linkByCode = linkServiceImpl.incrementClicks(linkByCode); } else { if (linkByCode.status().equalsIgnoreCase(LinkStatus.ACTIVE.name())) { - linkService.deactivateLink(linkByCode); + linkServiceImpl.deactivateLink(linkByCode); } throw new ShortLinkIsOutOfDateException( "Short link is out of date: " + code, diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8fa7c4b..8947d45 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -8,9 +8,6 @@ # ==================================================== logging: - level: - org.springdoc: DEBUG - springdoc: api-docs: path: /v3/api-docs @@ -21,7 +18,7 @@ springdoc: spring: profiles: - default: dev + default: dev # default profile if none specified server: port: ${SERVER_PORT:8080} @@ -30,8 +27,11 @@ link: expiration-days: ${LINK_EXPIRATION_DAYS:7} jwt: + secret: ${JWT_SECRET:dev-secret-change-me} ttl-seconds: ${JWT_TTL_SECONDS:3600} + + --- # ===================== DEV (H2 in-memory DB) ===================== spring: diff --git a/src/test/java/org/decepticons/linkshortener/api/controller/LinkCrudControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/controller/LinkCrudControllerTest.java new file mode 100644 index 0000000..475783a --- /dev/null +++ b/src/test/java/org/decepticons/linkshortener/api/controller/LinkCrudControllerTest.java @@ -0,0 +1,269 @@ +package org.decepticons.linkshortener.api.controller; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; +import org.decepticons.linkshortener.api.dto.LinkResponseDto; +import org.decepticons.linkshortener.api.dto.UpdateLinkExpirationRequestDto; +import org.decepticons.linkshortener.api.dto.UrlRequestDto; +import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; +import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.service.LinkService; +import org.decepticons.linkshortener.api.service.impl.UserServiceImpl; +import org.decepticons.linkshortener.api.v1.controller.LinkCrudController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + + + +@ExtendWith(MockitoExtension.class) +@DisplayName("Short Link Creation Tests") +class LinkCrudControllerTest { + + @Mock + private LinkService linkService; + + @Mock + UserServiceImpl userServiceImpl; + + + @InjectMocks + private LinkCrudController linkController; + + + @BeforeEach + void setUp() { + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("someName", null, null) + ); + + } + + @Test + @DisplayName("Create Short Link - Success") + void testCreateShortLinkSuccess() { + UrlRequestDto urlRequestDto = new UrlRequestDto(); + urlRequestDto.setUrl("https://example.com/some/long/url"); + + User fakeUser = new User(); + fakeUser.setUsername("someName"); + + + LinkResponseDto fakeResponse = new LinkResponseDto( + UUID.randomUUID(), + "abc123", + urlRequestDto.getUrl(), + Instant.now(), + Instant.now().plus(2, ChronoUnit.DAYS), + 0, + "ACTIVE", + fakeUser.getId() + ); + + + when(linkService.createLink(urlRequestDto)).thenReturn(fakeResponse); + + + ResponseEntity response = linkController.createLink(urlRequestDto); + + + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertEquals("abc123", response.getBody().code()); + assertEquals("https://example.com/some/long/url", response.getBody().originalUrl()); + + + + verify(linkService).createLink(urlRequestDto); + + } + + + @Test + @DisplayName("Create Short Link - User Not Found") + void testCreateShortLinkUserNotFoundController() { + UrlRequestDto urlRequestDto = new UrlRequestDto(); + urlRequestDto.setUrl("https://example.com/some/long/url"); + + + when(linkService.createLink(Mockito.any(UrlRequestDto.class))) + .thenThrow(new NoSuchUserFoundInTheSystemException("User not found", "some-user-id")); + + NoSuchUserFoundInTheSystemException ex = assertThrows( + NoSuchUserFoundInTheSystemException.class, + () -> linkController.createLink(urlRequestDto) + ); + + assertEquals("User not found", ex.getMessage()); + + verify(linkService).createLink(urlRequestDto); + } + + + + @Test + @DisplayName("Get All My Links - Success") + void getAllMyLinksSuccess() { + int page = 0; + int size = 10; + + LinkResponseDto link1 = new LinkResponseDto( + UUID.randomUUID(), + "code1", + "https://example.com/1", + Instant.now(), + Instant.now().plusSeconds(3600), + 0, + "ACTIVE", + UUID.randomUUID() + ); + + LinkResponseDto link2 = new LinkResponseDto( + UUID.randomUUID(), + "code2", + "https://example.com/2", + Instant.now(), + Instant.now().plusSeconds(3600), + 0, + "ACTIVE", + UUID.randomUUID() + ); + + Page mockPage = new PageImpl<>(List.of(link1, link2)); + + when(linkService.getAllMyLinks(page, size)).thenReturn(mockPage); + + ResponseEntity> response = linkController.getAllMyLinks(page, size); + + assertNotNull(response.getBody()); + assertEquals(2, response.getBody().getContent().size()); + assertEquals("code1", response.getBody().getContent().get(0).code()); + assertEquals("code2", response.getBody().getContent().get(1).code()); + + + verify(linkService, times(1)).getAllMyLinks(page, size); + + } + + @Test + @DisplayName("Get All My Active Links - Success") + void getAllMyActiveLinksSuccess() { + int page = 0; + int size = 10; + + + LinkResponseDto link1 = new LinkResponseDto( + UUID.randomUUID(), + "code1", + "https://example.com/1", + Instant.now(), + Instant.now().plusSeconds(3600), + 0, + "ACTIVE", + UUID.randomUUID() + ); + + LinkResponseDto link2 = new LinkResponseDto( + UUID.randomUUID(), + "code2", + "https://example.com/2", + Instant.now(), + Instant.now().plusSeconds(3600), + 0, + "ACTIVE", + UUID.randomUUID() + ); + + Page mockPage = new PageImpl<>(List.of(link1, link2)); + + + when(linkService.getAllMyActiveLinks(page, size)).thenReturn(mockPage); + + + ResponseEntity> response = linkController.getAllMyActiveLinks(page, size); + + + assertNotNull(response.getBody()); + assertEquals(2, response.getBody().getContent().size()); + assertEquals("code1", response.getBody().getContent().get(0).code()); + assertEquals("code2", response.getBody().getContent().get(1).code()); + + + verify(linkService, times(1)).getAllMyActiveLinks(page, size); + } + + + + @Test + @DisplayName("Delete Link - Success") + void testDeleteLinkSuccess() { + + UUID linkId = UUID.randomUUID(); + String mockCode = "abc123"; + + ResponseEntity mockResponse = ResponseEntity.noContent().build(); + + when(linkService.deleteLink(linkId)).thenReturn(mockCode); + + ResponseEntity response = linkController.deleteLink(linkId); + + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + assertNull(response.getBody()); + + verify(linkService, times(1)).deleteLink(linkId); + } + + @Test + @DisplayName("Update Link Expiration Date - Success") + void testUpdateLinkExpirationDate() { + + String code = "abc123"; + + UpdateLinkExpirationRequestDto requestDto + = new UpdateLinkExpirationRequestDto(Instant.now().plus(5, ChronoUnit.DAYS)); + + + when(linkService.updateLinkExpiration(code, requestDto.getNewExpirationDate())) + .thenReturn(new LinkResponseDto( + UUID.randomUUID(), + code, + "https://example.com/some/long/url", + Instant.now(), + requestDto.getNewExpirationDate(), + 0, + "ACTIVE", + UUID.randomUUID() + )); + + ResponseEntity response = + linkController.updateLinkExpiration(requestDto, code); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(code, response.getBody().code()); + verify(linkService, times(1)) + .updateLinkExpiration(code, requestDto.getNewExpirationDate()); + } +} diff --git a/src/test/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandlerTest.java b/src/test/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandlerTest.java index b44d524..50ded9a 100644 --- a/src/test/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandlerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/exceptions/GlobalExceptionHandlerTest.java @@ -1,6 +1,17 @@ package org.decepticons.linkshortener.api.exceptions; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import io.jsonwebtoken.JwtException; +import org.decepticons.linkshortener.api.exception.ExpiredTokenException; +import org.decepticons.linkshortener.api.exception.InvalidPasswordException; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; +import org.decepticons.linkshortener.api.v1.controller.unversioned.GlobalExceptionHandlerController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,10 +22,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - /** * Unit tests for the GlobalExceptionHandler. * These tests ensure that all custom exceptions are handled correctly @@ -23,187 +30,159 @@ @DisplayName("Global Exception Handler Tests") class GlobalExceptionHandlerTest { - private MockMvc mockMvc; - - @BeforeEach - void setup() { - // Given - mockMvc = MockMvcBuilders - .standaloneSetup(new TestController()) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); + private MockMvc mockMvc; + + @BeforeEach + void setup() { + // Given + mockMvc = MockMvcBuilders + .standaloneSetup(new TestController()) + .setControllerAdvice(new GlobalExceptionHandlerController()) + .build(); + } + + @Test + @DisplayName("given UserAlreadyExistsException, when thrown, then returns 409 CONFLICT") + void givenUserAlreadyExistsException_whenThrown_thenReturnConflict() throws Exception { + // Given + String expectedMessage = "User with username 'testuser' already exists"; + String expectedError = "User Already Exists"; + int expectedStatus = HttpStatus.CONFLICT.value(); + + // When & Then + mockMvc.perform(get("/test-user-exists")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + @Test + @DisplayName("given UserNotFoundException, when thrown, then returns 404 NOT FOUND") + void givenUserNotFoundException_whenThrown_thenReturnNotFound() throws Exception { + // Given + String expectedMessage = "User with username 'nonexistent' not found"; + String expectedError = "User Not Found"; + int expectedStatus = HttpStatus.NOT_FOUND.value(); + + // When & Then + mockMvc.perform(get("/test-user-not-found")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + @Test + @DisplayName("given InvalidPasswordException, when thrown, then returns 400 BAD REQUEST") + void givenInvalidPasswordException_whenThrown_thenReturnBadRequest() throws Exception { + // Given + String expectedMessage = "Password does not meet complexity requirements"; + String expectedError = "Invalid Password"; + int expectedStatus = HttpStatus.BAD_REQUEST.value(); + + // When & Then + mockMvc.perform(get("/test-invalid-password")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + @Test + @DisplayName("given InvalidTokenException, when thrown, then returns 401 UNAUTHORIZED") + void givenInvalidTokenException_whenThrown_thenReturnUnauthorized() throws Exception { + // Given + String expectedMessage = "Invalid Token"; + String expectedError = "Invalid Token"; + int expectedStatus = HttpStatus.UNAUTHORIZED.value(); + + // When & Then + mockMvc.perform(get("/test-invalid-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + @Test + @DisplayName("given ExpiredTokenException, when thrown, then returns 401 UNAUTHORIZED") + void givenExpiredTokenException_whenThrown_thenReturnUnauthorized() throws Exception { + // Given + String expectedMessage = "Expired token"; + String expectedError = "Expired Token"; + int expectedStatus = HttpStatus.UNAUTHORIZED.value(); + + // When & Then + mockMvc.perform(get("/test-expired-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + @Test + @DisplayName("given a generic Exception, when thrown, then returns 500 INTERNAL SERVER ERROR") + void givenGenericException_whenThrown_thenReturnInternalServerError() throws Exception { + // Given + String expectedMessage = "Something went wrong"; + String expectedError = "Server Error"; + int expectedStatus = HttpStatus.INTERNAL_SERVER_ERROR.value(); + + // When & Then + mockMvc.perform(get("/test-generic-exception")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.status").value(expectedStatus)) + .andExpect(jsonPath("$.error").value(expectedError)) + .andExpect(jsonPath("$.message").value(expectedMessage)); + } + + /** + * A simple dummy controller to trigger the exceptions for testing. + */ + @RestController + private static class TestController { + + + @GetMapping("/test-authentication-exception") + public void testAuthenticationException() { + throw new BadCredentialsException("Invalid username or password"); } - @Test - @DisplayName("given AuthenticationException (BadCredentialsException), when thrown, then returns 401 UNAUTHORIZED") - void givenAuthenticationException_whenThrown_thenReturnUnauthorized() throws Exception { - // Given - String expectedError = "Authentication Failed"; - int expectedStatus = HttpStatus.UNAUTHORIZED.value(); - - // When & Then - mockMvc.perform(get("/test-authentication-exception")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)); + @GetMapping("/test-jwt-exception") + public void testJwtException() { + throw new JwtException("JWT expired"); } - @Test - @DisplayName("given JwtException, when thrown, then returns 401 UNAUTHORIZED") - void givenJwtException_whenThrown_thenReturnUnauthorized() throws Exception { - // Given - String expectedError = "Invalid Token"; - String expectedMessage = "JWT expired"; - int expectedStatus = HttpStatus.UNAUTHORIZED.value(); - - // When & Then - mockMvc.perform(get("/test-jwt-exception")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); + @GetMapping("/test-user-exists") + public void testUserExists() { + throw new UserAlreadyExistsException("testuser"); } - @Test - @DisplayName("given UserAlreadyExistsException, when thrown, then returns 409 CONFLICT") - void givenUserAlreadyExistsException_whenThrown_thenReturnConflict() throws Exception { - // Given - String expectedMessage = "User with username 'testuser' already exists"; - String expectedError = "User Already Exists"; - int expectedStatus = HttpStatus.CONFLICT.value(); - - // When & Then - mockMvc.perform(get("/test-user-exists")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); - } - - @Test - @DisplayName("given UserNotFoundException, when thrown, then returns 404 NOT FOUND") - void givenUserNotFoundException_whenThrown_thenReturnNotFound() throws Exception { - // Given - String expectedMessage = "User with username 'nonexistent' not found"; - String expectedError = "User Not Found"; - int expectedStatus = HttpStatus.NOT_FOUND.value(); - - // When & Then - mockMvc.perform(get("/test-user-not-found")) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); - } - @Test - @DisplayName("given InvalidPasswordException, when thrown, then returns 400 BAD REQUEST") - void givenInvalidPasswordException_whenThrown_thenReturnBadRequest() throws Exception { - // Given - String expectedMessage = "Password does not meet complexity requirements"; - String expectedError = "Invalid Password"; - int expectedStatus = HttpStatus.BAD_REQUEST.value(); - - // When & Then - mockMvc.perform(get("/test-invalid-password")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); + @GetMapping("/test-user-not-found") + public void testUserNotFound() { + throw new UserNotFoundException("nonexistent"); } - @Test - @DisplayName("given InvalidTokenException, when thrown, then returns 401 UNAUTHORIZED") - void givenInvalidTokenException_whenThrown_thenReturnUnauthorized() throws Exception { - // Given - String expectedMessage = "Invalid Token"; - String expectedError = "Invalid Token"; - int expectedStatus = HttpStatus.UNAUTHORIZED.value(); - - // When & Then - mockMvc.perform(get("/test-invalid-token")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); + @GetMapping("/test-invalid-password") + public void testInvalidPassword() { + throw new InvalidPasswordException("Password does not meet complexity requirements"); } - @Test - @DisplayName("given ExpiredTokenException, when thrown, then returns 401 UNAUTHORIZED") - void givenExpiredTokenException_whenThrown_thenReturnUnauthorized() throws Exception { - // Given - String expectedMessage = "Expired token"; - String expectedError = "Expired Token"; - int expectedStatus = HttpStatus.UNAUTHORIZED.value(); - - // When & Then - mockMvc.perform(get("/test-expired-token")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); + @GetMapping("/test-invalid-token") + public void testInvalidToken() { + throw new InvalidTokenException("Invalid Token"); } - @Test - @DisplayName("given a generic Exception, when thrown, then returns 500 INTERNAL SERVER ERROR") - void givenGenericException_whenThrown_thenReturnInternalServerError() throws Exception { - // Given - String expectedMessage = "Something went wrong"; - String expectedError = "Server Error"; - int expectedStatus = HttpStatus.INTERNAL_SERVER_ERROR.value(); - - // When & Then - mockMvc.perform(get("/test-generic-exception")) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.status").value(expectedStatus)) - .andExpect(jsonPath("$.error").value(expectedError)) - .andExpect(jsonPath("$.message").value(expectedMessage)); + @GetMapping("/test-expired-token") + public void testExpiredToken() { + throw new ExpiredTokenException("Expired token"); } - /** - * A simple dummy controller to trigger the exceptions for testing. - */ - @RestController - private static class TestController { - - @GetMapping("/test-authentication-exception") - public void testAuthenticationException() { - throw new BadCredentialsException("Invalid username or password"); - } - - @GetMapping("/test-jwt-exception") - public void testJwtException() { - throw new JwtException("JWT expired"); - } - - @GetMapping("/test-user-exists") - public void testUserExists() { - throw new UserAlreadyExistsException("testuser"); - } - - @GetMapping("/test-user-not-found") - public void testUserNotFound() { - throw new UserNotFoundException("nonexistent"); - } - - @GetMapping("/test-invalid-password") - public void testInvalidPassword() { - throw new InvalidPasswordException("Password does not meet complexity requirements"); - } - - @GetMapping("/test-invalid-token") - public void testInvalidToken() { - throw new InvalidTokenException("Invalid Token"); - } - - @GetMapping("/test-expired-token") - public void testExpiredToken() { - throw new ExpiredTokenException("Expired token"); - } - - @GetMapping("/test-generic-exception") - public void testGenericException() throws Exception { - throw new Exception("Something went wrong"); - } + @GetMapping("/test-generic-exception") + public void testGenericException() throws Exception { + throw new Exception("Something went wrong"); } -} + } +} \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/model/RoleTest.java b/src/test/java/org/decepticons/linkshortener/api/model/RoleTest.java index 050b6d1..a1511fa 100644 --- a/src/test/java/org/decepticons/linkshortener/api/model/RoleTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/model/RoleTest.java @@ -1,9 +1,12 @@ package org.decepticons.linkshortener.api.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; @DisplayName("Role Class Unit Tests") class RoleTest { diff --git a/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java index c830d4b..21f1bef 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java @@ -1,5 +1,10 @@ package org.decepticons.linkshortener.api.security.controller; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.fasterxml.jackson.databind.ObjectMapper; import org.decepticons.linkshortener.api.dto.AuthRequestDto; import org.decepticons.linkshortener.api.dto.RegistrationRequestDto; @@ -22,14 +27,6 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import java.util.UUID; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - /** * Integration tests for the AuthController using Testcontainers. * These tests verify the full application stack's behavior for authentication @@ -40,174 +37,176 @@ @DisplayName("Auth Controller Integration Tests with Testcontainers") class AuthControllerTest { - private MockMvc mockMvc; + private MockMvc mockMvc; - @Autowired - private WebApplicationContext webApplicationContext; + @Autowired + private WebApplicationContext webApplicationContext; - @Autowired - private UserRepository userRepository; + @Autowired + private UserRepository userRepository; - @Autowired - private PasswordEncoder passwordEncoder; + @Autowired + private PasswordEncoder passwordEncoder; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = new ObjectMapper(); - // Use a PostgreSQL container for the tests - @Container - public static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("testdb") - .withUsername("testuser") - .withPassword("testpass"); + // Use a PostgreSQL container for the tests + @Container + public static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>( + "postgres:16-alpine") + .withDatabaseName("testdb") + .withUsername("testuser") + .withPassword("testpass"); - // Dynamically set the data source properties using the running container's details - @DynamicPropertySource + // Dynamically set the data source properties using the running container's details + @DynamicPropertySource static void setDatasourceProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); - registry.add("spring.datasource.username", postgresContainer::getUsername); - registry.add("spring.datasource.password", postgresContainer::getPassword); - registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); - registry.add("spring.flyway.enabled", () -> "true"); - registry.add("spring.flyway.locations", () -> "classpath:db/migration/postgresql"); - } - - @BeforeEach - void setup() { - mockMvc = MockMvcBuilders - .webAppContextSetup(webApplicationContext) - .build(); - userRepository.deleteAll(); // Clean up before each test - } - - @Test - @DisplayName("given a new user, when registering, then the user is successfully created and returns 200 OK") - void givenNewUser_whenRegistering_thenUserIsCreated() throws Exception { - // Given a new registration request - RegistrationRequestDto requestDto = new RegistrationRequestDto(); - requestDto.setUsername("testuser_new"); - requestDto.setPassword("Password123!"); - - // When the registration endpoint is called - mockMvc.perform(post("/api/v1/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(requestDto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$").value("testuser_new")); - - // Then verify the user exists in the database - assertTrue(userRepository.findByUsername("testuser_new").isPresent()); - } - - @Test - @DisplayName("given an existing user, when registering, then returns 409 Conflict") - void givenExistingUser_whenRegistering_thenReturnsConflict() throws Exception { - // Given an existing user in the database - User existingUser = new User(); - existingUser.setUsername("existinguser"); - existingUser.setPasswordHash(passwordEncoder.encode("Password123!")); - userRepository.save(existingUser); - - // When attempting to register the same user again - RegistrationRequestDto requestDto = new RegistrationRequestDto(); - requestDto.setUsername("existinguser"); - requestDto.setPassword("Password123!"); - - // Then an exception should be thrown - mockMvc.perform(post("/api/v1/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(requestDto))) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.message").value("User with username 'existinguser' already exists")); - } - - @Test - @DisplayName("given valid credentials, when logging in, then returns JWT tokens") - void givenValidCredentials_whenLoggingIn_thenReturnsJwtTokens() throws Exception { - // Given a registered user - RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); - registrationRequestDto.setUsername("loginuser"); - registrationRequestDto.setPassword("Password123!"); - mockMvc.perform(post("/api/v1/auth/register") + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.flyway.enabled", () -> "true"); + registry.add("spring.flyway.locations", () -> "classpath:db/migration/postgresql"); + } + + @BeforeEach + void setup() { + mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .build(); + userRepository.deleteAll(); // Clean up before each test + } + + @Test + @DisplayName("given a new user, when registering, then the user " + + "is successfully created and returns 200 OK") + void givenNewUser_whenRegistering_thenUserIsCreated() throws Exception { + // Given a new registration request + RegistrationRequestDto requestDto = new RegistrationRequestDto(); + requestDto.setUsername("testuser_new"); + requestDto.setPassword("Password123!"); + + // When the registration endpoint is called + mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(registrationRequestDto))); - - // When logging in with valid credentials - AuthRequestDto loginRequestDto = new AuthRequestDto(); - loginRequestDto.setUsername("loginuser"); - loginRequestDto.setPassword("Password123!"); - - // Then the response should contain access and refresh tokens - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequestDto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("loginuser")) - .andExpect(jsonPath("$.accessToken").exists()) - .andExpect(jsonPath("$.refreshToken").exists()); - } - - @Test - @DisplayName("given invalid password, when logging in, then returns 401 Unauthorized") - void givenInvalidPassword_whenLoggingIn_thenReturnsUnauthorized() throws Exception { - // Given a registered user - RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); - registrationRequestDto.setUsername("badpassuser"); - registrationRequestDto.setPassword("Password123!"); - mockMvc.perform(post("/api/v1/auth/register") + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").value("testuser_new")); + + // Then verify the user exists in the database + assertTrue(userRepository.findByUsername("testuser_new").isPresent()); + } + + @Test + @DisplayName("given an existing user, when registering, then returns 409 Conflict") + void givenExistingUser_whenRegistering_thenReturnsConflict() throws Exception { + // Given an existing user in the database + User existingUser = new User(); + existingUser.setUsername("existinguser"); + existingUser.setPasswordHash(passwordEncoder.encode("Password123!")); + userRepository.save(existingUser); + + // When attempting to register the same user again + RegistrationRequestDto requestDto = new RegistrationRequestDto(); + requestDto.setUsername("existinguser"); + requestDto.setPassword("Password123!"); + + // Then an exception should be thrown + mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(registrationRequestDto))); - - // When logging in with an invalid password - AuthRequestDto loginRequestDto = new AuthRequestDto(); - loginRequestDto.setUsername("badpassuser"); - loginRequestDto.setPassword("WrongPassword"); - - // Then the response should be 401 Unauthorized - mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequestDto))) - .andExpect(status().isUnauthorized()); - } - - @Test - @DisplayName("given a valid refresh token, when refreshing, then returns new JWT tokens") - void givenValidRefreshToken_whenRefreshing_thenReturnsNewTokens() throws Exception { - // Given a registered user with a valid refresh token - RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); - registrationRequestDto.setUsername("refreshuser"); - registrationRequestDto.setPassword("Password123!"); - mockMvc.perform(post("/api/v1/auth/register") + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("User with username 'existinguser' already exists")); + } + + @Test + @DisplayName("given valid credentials, when logging in, then returns JWT tokens") + void givenValidCredentials_whenLoggingIn_thenReturnsJwtTokens() throws Exception { + // Given a registered user + RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); + registrationRequestDto.setUsername("loginuser"); + registrationRequestDto.setPassword("Password123!"); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registrationRequestDto))); + + // When logging in with valid credentials + AuthRequestDto loginRequestDto = new AuthRequestDto(); + loginRequestDto.setUsername("loginuser"); + loginRequestDto.setPassword("Password123!"); + + // Then the response should contain access and refresh tokens + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("loginuser")) + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").exists()); + } + + @Test + @DisplayName("given invalid password, when logging in, then returns 401 Unauthorized") + void givenInvalidPassword_whenLoggingIn_thenReturnsUnauthorized() throws Exception { + // Given a registered user + RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); + registrationRequestDto.setUsername("badpassuser"); + registrationRequestDto.setPassword("Password123!"); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registrationRequestDto))); + + // When logging in with an invalid password + AuthRequestDto loginRequestDto = new AuthRequestDto(); + loginRequestDto.setUsername("badpassuser"); + loginRequestDto.setPassword("WrongPassword"); + + // Then the response should be 401 Unauthorized + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDto))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("given a valid refresh token, when refreshing, then returns new JWT tokens") + void givenValidRefreshToken_whenRefreshing_thenReturnsNewTokens() throws Exception { + // Given a registered user with a valid refresh token + RegistrationRequestDto registrationRequestDto = new RegistrationRequestDto(); + registrationRequestDto.setUsername("refreshuser"); + registrationRequestDto.setPassword("Password123!"); + mockMvc.perform(post("/api/v1/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(registrationRequestDto))); - AuthRequestDto loginRequestDto = new AuthRequestDto(); - loginRequestDto.setUsername("refreshuser"); - loginRequestDto.setPassword("Password123!"); - MvcResult result = mockMvc.perform(post("/api/v1/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(loginRequestDto))) - .andReturn(); - - String response = result.getResponse().getContentAsString(); - String refreshToken = objectMapper.readTree(response).get("refreshToken").asText(); - - // When refreshing the token - mockMvc.perform(post("/api/v1/auth/refresh") - .header("Authorization", "Bearer " + refreshToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").exists()) - .andExpect(jsonPath("$.refreshToken").exists()); - } - - @Test - @DisplayName("given an invalid refresh token, when refreshing, then returns 401 Unauthorized") - void givenInvalidRefreshToken_whenRefreshing_thenReturnsUnauthorized() throws Exception { - // Given an invalid token - String invalidToken = "invalid.token.123"; - - // When refreshing with the invalid token - mockMvc.perform(post("/api/v1/auth/refresh") - .header("Authorization", "Bearer " + invalidToken)) - .andExpect(status().isUnauthorized()); - } + AuthRequestDto loginRequestDto = new AuthRequestDto(); + loginRequestDto.setUsername("refreshuser"); + loginRequestDto.setPassword("Password123!"); + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequestDto))) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + String refreshToken = objectMapper.readTree(response).get("refreshToken").asText(); + + // When refreshing the token + mockMvc.perform(post("/api/v1/auth/refresh") + .header("Authorization", "Bearer " + refreshToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").exists()) + .andExpect(jsonPath("$.refreshToken").exists()); + } + + @Test + @DisplayName("given an invalid refresh token, when refreshing, then returns 401 Unauthorized") + void givenInvalidRefreshToken_whenRefreshing_thenReturnsUnauthorized() throws Exception { + // Given an invalid token + String invalidToken = "invalid.token.123"; + + // When refreshing with the invalid token + mockMvc.perform(post("/api/v1/auth/refresh") + .header("Authorization", "Bearer " + invalidToken)) + .andExpect(status().isUnauthorized()); + } } \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java index bde8a0a..e1c8d2d 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java @@ -1,10 +1,21 @@ package org.decepticons.linkshortener.api.security.jwt; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.decepticons.linkshortener.api.exceptions.InvalidTokenException; +import java.io.IOException; +import java.util.ArrayList; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,13 +28,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; -import java.io.IOException; -import java.util.ArrayList; - -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; /** * Unit tests for the JwtAuthenticationFilter. @@ -52,7 +56,8 @@ class JwtAuthenticationFilterTest { @InjectMocks private JwtAuthenticationFilter jwtAuthenticationFilter; - private final String authHeader = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciJ9.invalid-signature"; + private final String authHeader + = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciJ9.invalid-signature"; private UserDetails userDetails; @BeforeEach @@ -64,7 +69,8 @@ void setUp() { @Test @DisplayName("given a valid token, when filtering, then authenticates the user successfully") - void givenValidToken_whenFiltering_thenAuthenticatesSuccessfully() throws ServletException, IOException { + void givenValidToken_whenFiltering_thenAuthenticatesSuccessfully() + throws ServletException, IOException { // Given when(request.getHeader("Authorization")).thenReturn(authHeader); when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); @@ -96,7 +102,8 @@ void givenNoHeader_whenFiltering_thenSkipsAuthentication() throws ServletExcepti @Test @DisplayName("given a malformed header, when filtering, then throws InvalidTokenException") - void givenMalformedHeader_whenFiltering_thenThrowsInvalidTokenException() throws ServletException, IOException { + void givenMalformedHeader_whenFiltering_thenThrowsInvalidTokenException() + throws ServletException, IOException { // Given when(request.getHeader("Authorization")).thenReturn("MalformedToken"); @@ -110,7 +117,8 @@ void givenMalformedHeader_whenFiltering_thenThrowsInvalidTokenException() throws @Test @DisplayName("given an invalid token, when filtering, then throws InvalidTokenException") - void givenInvalidToken_whenFiltering_thenThrowsInvalidTokenException() throws ServletException, IOException { + void givenInvalidToken_whenFiltering_thenThrowsInvalidTokenException() + throws ServletException, IOException { // Given when(request.getHeader("Authorization")).thenReturn(authHeader); when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); @@ -125,8 +133,10 @@ void givenInvalidToken_whenFiltering_thenThrowsInvalidTokenException() throws Se } @Test - @DisplayName("given a valid token but a nonexistent user, when filtering, then skips authentication") - void givenTokenForNonexistentUser_whenFiltering_thenSkipsAuthentication() throws ServletException, IOException { + @DisplayName("given a valid token but a nonexistent user, " + + "when filtering, then skips authentication") + void givenTokenForNonexistentUser_whenFiltering_thenSkipsAuthentication() + throws ServletException, IOException { // Given when(request.getHeader("Authorization")).thenReturn(authHeader); when(jwtTokenUtil.extractUsername(anyString())).thenReturn("nonexistentuser"); diff --git a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtilTest.java b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtilTest.java index 77e15d8..642d18b 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtilTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtilTest.java @@ -1,19 +1,23 @@ package org.decepticons.linkshortener.api.security.jwt; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; +import java.util.Date; +import javax.crypto.SecretKey; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Date; -import javax.crypto.SecretKey; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * Unit tests for the JwtTokenUtil class. diff --git a/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java b/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java index 2c93b10..6ebb2f1 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java @@ -1,12 +1,16 @@ package org.decepticons.linkshortener.api.security.model; -import org.decepticons.linkshortener.api.model.User; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; import org.decepticons.linkshortener.api.model.Role; +import org.decepticons.linkshortener.api.model.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Collections; -import static org.junit.jupiter.api.Assertions.*; @DisplayName("CustomUserDetails Unit Tests") class CustomUserDetailsTest { diff --git a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java index f444def..41bf74d 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java @@ -1,9 +1,22 @@ package org.decepticons.linkshortener.api.security.service.impl; -import org.decepticons.linkshortener.api.exceptions.InvalidTokenException; -import org.decepticons.linkshortener.api.exceptions.UserAlreadyExistsException; -import org.decepticons.linkshortener.api.model.User; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.Optional; +import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; import org.decepticons.linkshortener.api.model.Role; +import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.model.UserStatus; import org.decepticons.linkshortener.api.repository.RoleRepository; import org.decepticons.linkshortener.api.repository.UserRepository; @@ -20,15 +33,10 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; -import java.util.Collections; -import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; /** * Unit tests for the AuthServiceImpl class. @@ -110,7 +118,8 @@ void givenInvalidCredentials_whenLogin_thenThrowsBadCredentialsException() { .thenThrow(new BadCredentialsException("Invalid credentials")); // When & Then - assertThrows(BadCredentialsException.class, () -> authService.login("testuser", "wrongpassword")); + assertThrows(BadCredentialsException.class, + () -> authService.login("testuser", "wrongpassword")); verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); verify(userAuthService, never()).findByUsername(anyString()); } @@ -134,7 +143,8 @@ void givenValidRefreshToken_whenRefreshing_thenReturnsUser() { } @Test - @DisplayName("given a null or malformed header, when refreshing, then throws InvalidTokenException") + @DisplayName("given a null or malformed header, " + + "when refreshing, then throws InvalidTokenException") void givenMalformedHeader_whenRefreshing_thenThrowsInvalidTokenException() { // When & Then assertThrows(InvalidTokenException.class, () -> authService.refreshToken(null)); diff --git a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java index d427044..f142b4d 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java @@ -1,8 +1,18 @@ package org.decepticons.linkshortener.api.security.service.impl; -import org.decepticons.linkshortener.api.exceptions.InvalidPasswordException; -import org.decepticons.linkshortener.api.exceptions.UserAlreadyExistsException; -import org.decepticons.linkshortener.api.exceptions.UserNotFoundException; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.decepticons.linkshortener.api.exception.InvalidPasswordException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -14,13 +24,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - /** * Unit tests for the UserAuthServiceImpl class. * These tests focus on the user registration and retrieval logic using mocks for dependencies. @@ -94,7 +97,8 @@ void givenInvalidPassword_whenRegistering_thenThrowsInvalidPasswordException() { when(userRepository.existsByUsername(anyString())).thenReturn(false); // When & Then - assertThrows(InvalidPasswordException.class, () -> userAuthService.registerUser(invalidPasswordUser)); + assertThrows(InvalidPasswordException.class, + () -> userAuthService.registerUser(invalidPasswordUser)); verify(userRepository, never()).save(any(User.class)); } @@ -114,13 +118,15 @@ void givenExistingUsername_whenFindingByUsername_thenReturnsUser() { } @Test - @DisplayName("given a nonexistent username, when finding by username, then throws UserNotFoundException") + @DisplayName("given a nonexistent username, " + + "when finding by username, then throws UserNotFoundException") void givenNonexistentUsername_whenFindingByUsername_thenThrowsUserNotFoundException() { // Given when(userRepository.findByUsername("nonexistentuser")).thenReturn(Optional.empty()); // When & Then - assertThrows(UserNotFoundException.class, () -> userAuthService.findByUsername("nonexistentuser")); + assertThrows(UserNotFoundException.class, + () -> userAuthService.findByUsername("nonexistentuser")); verify(userRepository).findByUsername("nonexistentuser"); } } \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceMethodsTest.java b/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplMethodsTest.java similarity index 68% rename from src/test/java/org/decepticons/linkshortener/api/service/LinkServiceMethodsTest.java rename to src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplMethodsTest.java index 2105078..72edfb9 100644 --- a/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceMethodsTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplMethodsTest.java @@ -1,47 +1,54 @@ package org.decepticons.linkshortener.api.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.decepticons.linkshortener.api.dto.LinkResponseDto; import org.decepticons.linkshortener.api.model.Link; import org.decepticons.linkshortener.api.model.LinkStatus; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.LinkRepository; -import org.decepticons.linkshortener.api.repository.UserRepository; +import org.decepticons.linkshortener.api.service.impl.LinkServiceImpl; +import org.decepticons.linkshortener.api.service.impl.UserServiceImpl; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.junit.jupiter.api.extension.ExtendWith; - import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import org.springframework.test.util.ReflectionTestUtils; +/** * Unit tests for the LinkServiceImpl class. + * These tests focus on the link management logic using mocks for dependencies. + */ @ExtendWith(MockitoExtension.class) -public class LinkServiceMethodsTest { +public class LinkServiceImplMethodsTest { @Mock private LinkRepository linkRepository; @Mock - private UserRepository userRepository; + private UserServiceImpl userServiceImpl; - @Mock - private CacheEvictService cacheEvictService; @InjectMocks - private LinkService linkService; + private LinkServiceImpl linkService; private final UUID testUserId = UUID.randomUUID(); private final String testUsername = "testuser"; @@ -55,9 +62,7 @@ void setUpSecurityContext() { User testUser = new User(); - testUser.setId(testUserId); testUser.setUsername(testUsername); - when(userRepository.findByUsername(testUsername)).thenReturn(Optional.of(testUser)); } @AfterEach @@ -67,12 +72,14 @@ void clearSecurityContext() { @Test @DisplayName("Get All My Links - Success") - void getAllMyLinks_SUCCESS() { + void getAllMyLinksSuccess() { Link link1 = new Link(); - link1.setOwner(userRepository.findByUsername(testUsername).get()); + link1.setOwner(new User()); Page mockPage = new PageImpl<>(List.of(link1)); + + when(userServiceImpl.getCurrentUserId()).thenReturn(testUserId); when(linkRepository.findAllByOwnerId(eq(testUserId), any(Pageable.class))) .thenReturn(mockPage); @@ -85,14 +92,16 @@ void getAllMyLinks_SUCCESS() { @Test @DisplayName("Get All My Active Links - Success") - void getAllMyActiveLinks_SUCCESS() { + void getAllMyActiveLinksSuccess() { Link link1 = new Link(); link1.setStatus(LinkStatus.ACTIVE); - link1.setOwner(userRepository.findByUsername(testUsername).get()); + link1.setOwner(new User()); Page mockPage = new PageImpl<>(List.of(link1)); - when(linkRepository.findAllByOwnerIdAndStatus(eq(testUserId), eq(LinkStatus.ACTIVE), any(Pageable.class))) + when(userServiceImpl.getCurrentUserId()).thenReturn(testUserId); + when(linkRepository.findAllByOwnerIdAndStatus(eq(testUserId), + eq(LinkStatus.ACTIVE), any(Pageable.class))) .thenReturn(mockPage); Page result = linkService.getAllMyActiveLinks(0, 10); @@ -104,18 +113,21 @@ void getAllMyActiveLinks_SUCCESS() { @Test @DisplayName("Delete Link - Success") - void deleteLink_SUCCESS() { - UUID linkId = UUID.randomUUID(); + void deleteLinkSuccess() { + User owner = new User(); + ReflectionTestUtils.setField(owner, "id", testUserId); + owner.setUsername(testUsername); Link link = new Link(); link.setCode("abc123"); - link.setOwner(userRepository.findByUsername(testUsername).get()); + link.setOwner(owner); + UUID linkId = UUID.randomUUID(); + when(userServiceImpl.getCurrentUserId()).thenReturn(testUserId); when(linkRepository.findById(linkId)).thenReturn(Optional.of(link)); linkService.deleteLink(linkId); verify(linkRepository, times(1)).delete(link); - verify(cacheEvictService, times(1)).evictLink("abc123"); } diff --git a/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceTest.java b/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplTest.java similarity index 63% rename from src/test/java/org/decepticons/linkshortener/api/service/LinkServiceTest.java rename to src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplTest.java index d285813..e6b5956 100644 --- a/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/service/LinkServiceImplTest.java @@ -1,69 +1,70 @@ package org.decepticons.linkshortener.api.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.UUID; import org.decepticons.linkshortener.api.dto.LinkResponseDto; import org.decepticons.linkshortener.api.dto.UrlRequestDto; import org.decepticons.linkshortener.api.model.Link; import org.decepticons.linkshortener.api.model.LinkStatus; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.LinkRepository; -import org.decepticons.linkshortener.api.repository.UserRepository; +import org.decepticons.linkshortener.api.service.impl.LinkServiceImpl; +import org.decepticons.linkshortener.api.service.impl.UserServiceImpl; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) -class LinkServiceTest { +class LinkServiceImplTest { @Mock private LinkRepository linkRepository; - @InjectMocks - private LinkService linkService; + @Mock - private UserRepository userRepository; + private UserServiceImpl userServiceImpl; + + @InjectMocks + private LinkServiceImpl linkServiceImpl; + - @BeforeEach - void setUp() { - linkRepository = mock(LinkRepository.class); - linkService = new LinkService(linkRepository, null, null); - } @Test @DisplayName("Link Creation - Success") - void testLinkCreation_Success() { + void testLinkCreationSuccess() { UrlRequestDto urlRequestDto = new UrlRequestDto(); User user = new User(); - user.setId(UUID.randomUUID()); urlRequestDto.setUrl("https://www.example.com"); - + when(userServiceImpl.getCurrentUser()).thenReturn(user); when(linkRepository.save(any(Link.class))).thenAnswer(i -> i.getArguments()[0]); - LinkResponseDto result = linkService.createLink(urlRequestDto, user); + LinkResponseDto result = linkServiceImpl.createLink(urlRequestDto); assertEquals(urlRequestDto.getUrl(), result.originalUrl()); assertEquals(user.getId(), result.ownerId()); @@ -73,7 +74,12 @@ void testLinkCreation_Success() { @Test @DisplayName("Increment of Clicks - Success") - void incrementOfClicks_Success() { + void incrementOfClicksSuccess() { + Link link = new Link(); + link.setOwner(new User()); + link.setCode("abc123"); + link.setClicks(0); + LinkResponseDto linkResponseDto = new LinkResponseDto( UUID.randomUUID(), "abc123", @@ -84,26 +90,19 @@ void incrementOfClicks_Success() { "ACTIVE", UUID.randomUUID() ); - Link link = new Link(); - link.setOwner(new User()); - link.setCode("abc123"); - link.setClicks(0); + when(linkRepository.incrementClicksByCodeNative("abc123")).thenReturn(1); when(linkRepository.findByCode("abc123")).thenReturn(Optional.of(link)); - when(linkRepository.save(any(Link.class))).thenAnswer(i -> i.getArguments()[0]); + LinkResponseDto result = linkServiceImpl.incrementClicks(linkResponseDto); + assertNotEquals(1, result.clicks()); - - linkService.incrementClicks(linkResponseDto); - - assertNotEquals(0, link.getClicks()); } @Test @DisplayName("Mapping to Response DTO - Success") - void testMapToResponse_Success() { + void testMapToResponseSuccess() { UUID userId = UUID.randomUUID(); User owner = new User(); - owner.setId(userId); Link link = new Link(); link.setCode("abc123"); @@ -113,7 +112,7 @@ void testMapToResponse_Success() { link.setExpiresAt(Instant.now().plus(2, ChronoUnit.DAYS)); link.setOwner(owner); - LinkResponseDto dto = linkService.mapToResponse(link); + LinkResponseDto dto = linkServiceImpl.mapToResponse(link); assertEquals(link.getCode(), dto.code()); assertEquals(link.getOriginalUrl(), dto.originalUrl()); @@ -127,10 +126,9 @@ void testMapToResponse_Success() { @Test @DisplayName("Get Link By Code - Success") - void getLinkByCode_Success() { + void getLinkByCodeSuccess() { User owner = new User(); - owner.setId(UUID.randomUUID()); Link link = new Link(); link.setOwner(owner); @@ -143,7 +141,7 @@ void getLinkByCode_Success() { when(linkRepository.findByCode("abc123")).thenReturn(Optional.of(link)); - LinkResponseDto result = linkService.getLinkByCode("abc123"); + LinkResponseDto result = linkServiceImpl.getLinkByCode("abc123"); assertEquals("abc123", result.code()); assertEquals("https://example.com", result.originalUrl()); @@ -156,9 +154,15 @@ void getLinkByCode_Success() { @Test @DisplayName("Deactivate Link - Success") - void deactivateLink_Success() { + void deactivateLinkSuccess() { User owner = new User(); + + Link link = new Link(); + link.setOwner(owner); + link.setCode("abc123"); + link.setStatus(LinkStatus.ACTIVE); + LinkResponseDto linkResponseDto = new LinkResponseDto( UUID.randomUUID(), "abc123", @@ -170,16 +174,10 @@ void deactivateLink_Success() { UUID.randomUUID() ); - Link link = new Link(); - link.setOwner(owner); - link.setCode("abc123"); - link.setStatus(LinkStatus.ACTIVE); - - when(linkRepository.findByCode("abc123")).thenReturn(Optional.of(link)); when(linkRepository.save(any(Link.class))).thenAnswer(i -> i.getArguments()[0]); - LinkResponseDto result = linkService.deactivateLink(linkResponseDto); + LinkResponseDto result = linkServiceImpl.deactivateLink(linkResponseDto); assertEquals("INACTIVE", result.status()); } @@ -187,7 +185,7 @@ void deactivateLink_Success() { @Test @DisplayName("Validate Link - Success") - void validateLink_Success() { + void validateLinkSuccess() { LinkResponseDto linkResponseDto = new LinkResponseDto( UUID.randomUUID(), "abc123", @@ -199,8 +197,41 @@ void validateLink_Success() { UUID.randomUUID() ); - linkService.validateLink(linkResponseDto); - Assertions.assertTrue(linkService.validateLink(linkResponseDto)); + linkServiceImpl.validateLink(linkResponseDto); + Assertions.assertTrue(linkServiceImpl.validateLink(linkResponseDto)); + + } + + @Test + @DisplayName("Update Link Expiration - Success") + void testUpdateLinkExpiration() { + String code = "abc123"; + User owner = new User(); + owner.setUsername("testUser"); + ReflectionTestUtils.setField(owner, "id", UUID.randomUUID()); + + Link link = new Link(); + link.setCode(code); + link.setExpiresAt(Instant.now().plusSeconds(3600)); + link.setStatus(LinkStatus.ACTIVE); + link.setOwner(owner); + + Authentication auth = mock(Authentication.class); + SecurityContext securityContext = mock(SecurityContext.class); + SecurityContextHolder.setContext(securityContext); + + when(linkRepository.findByCode(code)).thenReturn(Optional.of(link)); + when(linkRepository.save(any(Link.class))).thenAnswer(i -> i.getArguments()[0]); + when(userServiceImpl.getCurrentUserId()).thenReturn(owner.getId()); + + LinkResponseDto response = linkServiceImpl.updateLinkExpiration(code, Instant + .now() + .plusSeconds(7200)); + + assertNotNull(response); + assertEquals(code, response.code()); + assertEquals(link.getExpiresAt(), response.expiresAt()); + verify(linkRepository, times(1)).findByCode(code); } } diff --git a/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java new file mode 100644 index 0000000..5003d0f --- /dev/null +++ b/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java @@ -0,0 +1,102 @@ +package org.decepticons.linkshortener.api.service; + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; +import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.repository.UserRepository; +import org.decepticons.linkshortener.api.service.impl.UserServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Unit tests for the UserServiceImpl class. + * These tests focus on the user retrieval logic using mocks for dependencies. + */ +@ExtendWith(MockitoExtension.class) +public class UserServiceImplTest { + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserServiceImpl userService; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + @BeforeEach + void setUp() { + when(authentication.getName()).thenReturn("testuser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + + + org.springframework.security.core.context.SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("Test getCurrentUser returns correct User") + void testGetCurrentUserSuccess() { + User fakeUser = new User(); + fakeUser.setUsername("testuser"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(fakeUser)); + + User result = userService.getCurrentUser(); + + assertNotNull(result); + assertEquals("testuser", result.getUsername()); + } + + @Test + @DisplayName("Test getCurrentUserId returns correct UUID") + void testGetCurrentUserIdSuccess() { + UUID fakeId = UUID.randomUUID(); + User fakeUser = new User(); + ReflectionTestUtils.setField(fakeUser, "id", fakeId); + fakeUser.setUsername("testuser"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(fakeUser)); + + UUID result = userService.getCurrentUserId(); + + assertEquals(fakeId, result); + } + + @Test + @DisplayName("Test getCurrentUser throws exception when user not found") + void testGetCurrentUserUserNotFound() { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.empty()); + + NoSuchUserFoundInTheSystemException ex = assertThrows( + NoSuchUserFoundInTheSystemException.class, + () -> userService.getCurrentUser() + ); + + assertTrue(ex.getMessage().contains("No such user found in the system")); + } + +} diff --git a/src/test/java/org/decepticons/linkshortener/api/util/AuthMapperTest.java b/src/test/java/org/decepticons/linkshortener/api/util/AuthMapperTest.java index 70503ba..178e322 100644 --- a/src/test/java/org/decepticons/linkshortener/api/util/AuthMapperTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/util/AuthMapperTest.java @@ -1,17 +1,19 @@ package org.decepticons.linkshortener.api.util; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.Collections; +import java.util.List; import org.decepticons.linkshortener.api.dto.AuthResponseDto; import org.decepticons.linkshortener.api.dto.RegistrationRequestDto; -import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.model.Role; +import org.decepticons.linkshortener.api.model.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.core.GrantedAuthority; -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; @DisplayName("AuthMapper Unit Tests") class AuthMapperTest { @@ -47,7 +49,8 @@ void shouldMapUserAndTokensToAuthResponseDto() { String refreshToken = "mocked.refresh.token"; // When - AuthResponseDto responseDto = AuthMapper.toAuthResponseDto(user, authorities, accessToken, refreshToken); + AuthResponseDto responseDto + = AuthMapper.toAuthResponseDto(user, authorities, accessToken, refreshToken); // Then assertNotNull(responseDto); diff --git a/src/test/java/org/decepticons/linkshortener/api/util/PasswordValidatorTest.java b/src/test/java/org/decepticons/linkshortener/api/util/PasswordValidatorTest.java index 61ac15a..e5074e2 100644 --- a/src/test/java/org/decepticons/linkshortener/api/util/PasswordValidatorTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/util/PasswordValidatorTest.java @@ -1,10 +1,12 @@ package org.decepticons.linkshortener.api.util; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + + /** * Unit tests for the PasswordValidator class. * These tests ensure that the password complexity rules are correctly enforced. diff --git a/src/test/java/org/decepticons/linkshortener/api/v1/controller/CacheControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/v1/controller/CacheControllerTest.java deleted file mode 100644 index 17cd19d..0000000 --- a/src/test/java/org/decepticons/linkshortener/api/v1/controller/CacheControllerTest.java +++ /dev/null @@ -1,254 +0,0 @@ -package org.decepticons.linkshortener.api.v1.controller; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.UUID; -import org.decepticons.linkshortener.api.dto.LinkResponseDto; -import org.decepticons.linkshortener.api.dto.UrlRequestDto; -import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; -import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.service.CacheInspectionService; -import org.decepticons.linkshortener.api.service.LinkService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) - class CacheControllerTest { - @Mock - private CacheInspectionService cacheInspectionService; - - @InjectMocks - private CacheController cacheController; - - @Test - @DisplayName("Inspect Cache - Service Called") - void inspectCache_SUCCESS() { - - cacheController.inspectCache(); - - verify(cacheInspectionService, times(1)).printCache("shortLinksCache"); - } - - @ExtendWith(MockitoExtension.class) - @DisplayName("Short Link Creation Tests") - static - class LinkCrudControllerTest { - - @Mock - private LinkService linkService; - - - @InjectMocks - private LinkCrudController linkController; - - - @BeforeEach - void setUp() { - - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken("someName", null, null) - ); - - } - - @Test - @DisplayName("Create Short Link - Success") - void testCreateShortLink_Success() { - UrlRequestDto urlRequestDto = new UrlRequestDto(); - urlRequestDto.setUrl("https://example.com/some/long/url"); - - User fakeUser = new User(); - fakeUser.setId(UUID.randomUUID()); - fakeUser.setUsername("someName"); - - - LinkResponseDto fakeResponse = new LinkResponseDto( - UUID.randomUUID(), - "abc123", - urlRequestDto.getUrl(), - Instant.now(), - Instant.now().plus(2, ChronoUnit.DAYS), - 0, - "ACTIVE", - fakeUser.getId() - ); - - when(linkService.getCurrentUser()).thenReturn(fakeUser); - - - when(linkService.createLink(urlRequestDto, fakeUser)).thenReturn(fakeResponse); - - - ResponseEntity response = linkController.createLink(urlRequestDto); - - - assertEquals(HttpStatus.CREATED, response.getStatusCode()); - assertEquals("abc123", response.getBody().code()); - assertEquals("https://example.com/some/long/url", response.getBody().originalUrl()); - - - verify(linkService).getCurrentUser(); - verify(linkService).createLink(urlRequestDto, fakeUser); - - } - - - @Test - @DisplayName("Create Short Link - User Not Found") - void testCreateShortLink_UserNotFound() { - - UrlRequestDto urlRequestDto = new UrlRequestDto(); - urlRequestDto.setUrl("https://example.com/some/long/url"); - - - when(linkService.getCurrentUser()) - .thenThrow(new NoSuchUserFoundInTheSystemException("No such user", "someName")); - - - Exception ex = null; - try { - linkController.createLink(urlRequestDto); - }catch (Exception exception){ - ex = exception; - } - - assertNotNull(ex); - assertInstanceOf(NoSuchUserFoundInTheSystemException.class, ex); - - verify(linkService, Mockito.never()).createLink(Mockito.any(), Mockito.any()); - } - - @Test - @DisplayName("Get All My Links - Success") - void getAllMyLinks_SUCCESS() { - int page = 0; - int size = 10; - - LinkResponseDto link1 = new LinkResponseDto( - UUID.randomUUID(), - "code1", - "https://example.com/1", - Instant.now(), - Instant.now().plusSeconds(3600), - 0, - "ACTIVE", - UUID.randomUUID() - ); - - LinkResponseDto link2 = new LinkResponseDto( - UUID.randomUUID(), - "code2", - "https://example.com/2", - Instant.now(), - Instant.now().plusSeconds(3600), - 0, - "ACTIVE", - UUID.randomUUID() - ); - - Page mockPage = new PageImpl<>(List.of(link1, link2)); - - when(linkService.getAllMyLinks(page, size)).thenReturn(mockPage); - - ResponseEntity> response = linkController.getAllMyLinks(page, size); - - assertNotNull(response.getBody()); - assertEquals(2, response.getBody().getContent().size()); - assertEquals("code1", response.getBody().getContent().get(0).code()); - assertEquals("code2", response.getBody().getContent().get(1).code()); - - - verify(linkService, times(1)).getAllMyLinks(page, size); - - } - - @Test - @DisplayName("Get All My Active Links - Success") - void getAllMyActiveLinks_SUCCESS() { - int page = 0; - int size = 10; - - - LinkResponseDto link1 = new LinkResponseDto( - UUID.randomUUID(), - "code1", - "https://example.com/1", - Instant.now(), - Instant.now().plusSeconds(3600), - 0, - "ACTIVE", - UUID.randomUUID() - ); - - LinkResponseDto link2 = new LinkResponseDto( - UUID.randomUUID(), - "code2", - "https://example.com/2", - Instant.now(), - Instant.now().plusSeconds(3600), - 0, - "ACTIVE", - UUID.randomUUID() - ); - - Page mockPage = new PageImpl<>(List.of(link1, link2)); - - - when(linkService.getAllMyActiveLinks(page, size)).thenReturn(mockPage); - - - ResponseEntity> response = linkController.getAllMyActiveLinks(page, size); - - - assertNotNull(response.getBody()); - assertEquals(2, response.getBody().getContent().size()); - assertEquals("code1", response.getBody().getContent().get(0).code()); - assertEquals("code2", response.getBody().getContent().get(1).code()); - - - verify(linkService, times(1)).getAllMyActiveLinks(page, size); - } - - - - @Test - @DisplayName("Delete Link - Success") - void testDeleteLink_SUCCESS() { - - UUID linkId = UUID.randomUUID(); - - doNothing().when(linkService).deleteLink(linkId); - - ResponseEntity response = linkController.deleteLink(linkId); - - assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); - assertNull(response.getBody()); - - verify(linkService, times(1)).deleteLink(linkId); - } - - } -} diff --git a/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudControllerTest.java index 45e2747..0cce143 100644 --- a/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudControllerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkCrudControllerTest.java @@ -1,9 +1,21 @@ package org.decepticons.linkshortener.api.v1.controller; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; import org.decepticons.linkshortener.api.dto.LinkResponseDto; -import org.decepticons.linkshortener.api.service.LinkService; +import org.decepticons.linkshortener.api.service.impl.LinkServiceImpl; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -11,18 +23,11 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.Collections; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.anyInt; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @SpringBootTest @AutoConfigureMockMvc class LinkCrudControllerTest { @@ -31,7 +36,7 @@ class LinkCrudControllerTest { private MockMvc mockMvc; @MockitoBean - private LinkService linkService; + private LinkServiceImpl linkServiceImpl; @Test @WithMockUser(username = "testuser", roles = {"USER"}) @@ -53,7 +58,7 @@ void testGetAllMyLinks() throws Exception { 1 ); - Mockito.when(linkService.getAllMyLinks(anyInt(), anyInt())).thenReturn(page); + when(linkServiceImpl.getAllMyLinks(anyInt(), anyInt())).thenReturn(page); mockMvc.perform(get("/api/v1/links/my_all_links") .param("page", "0") @@ -85,7 +90,7 @@ void testGetAllMyActiveLinks() throws Exception { 1 ); - Mockito.when(linkService.getAllMyActiveLinks(anyInt(), anyInt())).thenReturn(page); + when(linkServiceImpl.getAllMyActiveLinks(anyInt(), anyInt())).thenReturn(page); mockMvc.perform(get("/api/v1/links/my_all_active_links") .param("page", "0") @@ -100,12 +105,17 @@ void testGetAllMyActiveLinks() throws Exception { @Test @WithMockUser(username = "testuser", roles = {"USER"}) void testDeleteLinkSuccess() throws Exception { + ResponseEntity responseEntity = ResponseEntity.noContent().build(); UUID existingId = UUID.randomUUID(); - Mockito.doNothing().when(linkService).deleteLink(existingId); + + when(linkServiceImpl.deleteLink(existingId)) + .thenReturn(existingId.toString()); mockMvc.perform(delete("/api/v1/links/delete/{id}", existingId.toString())) .andExpect(status().isNoContent()); + + verify(linkServiceImpl, times(1)).deleteLink(existingId); } } diff --git a/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkShortenerApplicationTests.java b/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkShortenerApplicationTests.java index 1023eed..bc3020c 100644 --- a/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkShortenerApplicationTests.java +++ b/src/test/java/org/decepticons/linkshortener/api/v1/controller/LinkShortenerApplicationTests.java @@ -1,5 +1,9 @@ package org.decepticons.linkshortener.api.v1.controller; +import static org.assertj.core.api.Assertions.assertThat; + +import org.decepticons.linkshortener.api.security.jwt.JwtAuthenticationFilter; +import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil; import org.decepticons.linkshortener.api.v1.controller.unversioned.HealthController; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -7,25 +11,27 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @AutoConfigureMockMvc(addFilters = false) class LinkShortenerApplicationTests { - @Autowired ApplicationContext context; + @Autowired ApplicationContext context; - @MockitoBean - org.decepticons.linkshortener.api.security.jwt.JwtAuthenticationFilter jwtFilter; - @MockitoBean org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil jwtTokenUtil; + @MockitoBean + JwtAuthenticationFilter jwtFilter; + @MockitoBean + JwtTokenUtil jwtTokenUtil; - @Test - void contextLoads() { assertThat(context).isNotNull(); } + @Test + void contextLoads() { + assertThat(context).isNotNull(); + } - @Test - void healthControllerIsLoaded() { - assertThat(context.getBean( - HealthController.class)).isNotNull(); - } + @Test +void healthControllerIsLoaded() { + assertThat(context.getBean( + HealthController.class)).isNotNull(); + } } diff --git a/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/HealthControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/HealthControllerTest.java index bcbc391..17bd01e 100644 --- a/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/HealthControllerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/HealthControllerTest.java @@ -1,5 +1,10 @@ package org.decepticons.linkshortener.api.v1.controller.unversioned; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import org.decepticons.linkshortener.api.security.jwt.JwtAuthenticationFilter; import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil; import org.junit.jupiter.api.Test; @@ -9,24 +14,22 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(controllers = HealthController.class) @AutoConfigureMockMvc(addFilters = false) // disable Spring Security filters class HealthControllerTest { - @Autowired - private MockMvc mvc; + @Autowired + private MockMvc mvc; - @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter; - @MockitoBean private JwtTokenUtil jwtTokenUtil; + @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter; + @MockitoBean private JwtTokenUtil jwtTokenUtil; - @Test - void health_returnsUp() throws Exception { - mvc.perform(get("/health")) - .andExpect(status().isOk()) - .andExpect(content().contentType("application/json")) - .andExpect(jsonPath("$.status").value("UP")); - } + @Test + void health_returnsUp() throws Exception { + mvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.status").value("UP")); + } } diff --git a/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectControllerTest.java index 5f745ee..0ec74ab 100644 --- a/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectControllerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/v1/controller/unversioned/RedirectControllerTest.java @@ -1,18 +1,25 @@ package org.decepticons.linkshortener.api.v1.controller.unversioned; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; import org.decepticons.linkshortener.api.dto.LinkResponseDto; +import org.decepticons.linkshortener.api.exception.ShortLinkIsOutOfDateException; import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.service.LinkService; +import org.decepticons.linkshortener.api.service.impl.LinkServiceImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import java.io.IOException; -import java.time.Instant; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.mockito.Mockito.*; + @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class RedirectControllerTest { @@ -20,15 +27,15 @@ class RedirectControllerTest { @InjectMocks private RedirectController redirectController; @Mock - private LinkService linkService; + private LinkServiceImpl linkServiceImpl; @Mock private HttpServletResponse httpServletResponse; @Test - void verifyRedirectToOriginalUrl_SUCCESS() throws IOException { + void verifyRedirectToOriginalUrlSuccess() throws IOException { String code = "abc123"; User owner = new User(); - owner.setId(java.util.UUID.randomUUID()); + LinkResponseDto responseDto = new LinkResponseDto( java.util.UUID.randomUUID(), @@ -42,23 +49,23 @@ void verifyRedirectToOriginalUrl_SUCCESS() throws IOException { ); - when(linkService.getLinkByCode(code)).thenReturn(responseDto); - when(linkService.incrementClicks(responseDto)).thenReturn(responseDto); - when(linkService.validateLink(responseDto)).thenReturn(true); + when(linkServiceImpl.getLinkByCode(code)).thenReturn(responseDto); + when(linkServiceImpl.incrementClicks(responseDto)).thenReturn(responseDto); + when(linkServiceImpl.validateLink(responseDto)).thenReturn(true); doNothing().when(httpServletResponse).sendRedirect(responseDto.originalUrl()); redirectController.redirect(code, httpServletResponse); verify(httpServletResponse, times(1)).sendRedirect("https://www.example.com"); - verify(linkService, times(1)).incrementClicks(responseDto); + verify(linkServiceImpl, times(1)).incrementClicks(responseDto); } @Test - void verifyExceptionThrownWhenLinkNotValid_SUCCESS() throws IOException { + void verifyExceptionThrownWhenLinkNotValidSuccess() throws IOException { String code = "abc123"; User owner = new User(); - owner.setId(java.util.UUID.randomUUID()); + LinkResponseDto responseDto = new LinkResponseDto( java.util.UUID.randomUUID(), @@ -71,19 +78,19 @@ void verifyExceptionThrownWhenLinkNotValid_SUCCESS() throws IOException { owner.getId() ); - when(linkService.getLinkByCode(code)).thenReturn(responseDto); - when(linkService.validateLink(responseDto)).thenReturn(false ); + when(linkServiceImpl.getLinkByCode(code)).thenReturn(responseDto); + when(linkServiceImpl.validateLink(responseDto)).thenReturn(false); Exception ex = null; try { redirectController.redirect(code, httpServletResponse); - }catch (Exception e) { + } catch (Exception e) { ex = e; } verify(httpServletResponse, never()).sendRedirect("https://www.example.com"); - assertInstanceOf(org.decepticons.linkshortener.api.exception.ShortLinkIsOutOfDateException.class, ex); + assertInstanceOf(ShortLinkIsOutOfDateException.class, ex); } }