diff --git a/README.md b/README.md index 2cf454a..c198fee 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ -# java-filmorate -Template repository for Filmorate project. +# Filmorate + +## О проекте + +Filmorate - это RESTful API для управления фильмами и пользователями. +Сейчас приложение позволяет добавлять, обновлять и просматривать информацию о фильмах и пользователях. + +## Модели данных + +### Film +```json +{ + "id": 0, // требуется только для обновления + "name": "string", // обязательное поле + "description": "string", // опциональное поле, не может быть больше 200 символов + "releaseDate": "2000-01-01", // опциональное поле, должно быть после 1985-01-28 + "duration": 10 // опциональное поле, продолжительность в минутах, должно быть больше 0 +} +``` + + + +### User +```json +{ + "id": 0, // требуется только для обновления + "email": "string@string.string", // обязательное поле, должно быть формата name@domain + "login": "string", // обязательное поле, не должно быть пустым и содержать пробелы + "name": "string", // опциональное поле, при отсутсвии совпадает с login + "birthday": 10 // опциональное поле, дата рождения, не должна быть в будущем +} +``` + +## API Endpoints + +### Фильмы +``` +GET /films - получить все фильмы +POST /films - создать новый фильм +PUT /films - обновить существующий фильм +``` + +### Пользователи +``` +GET /users - получить всех пользователей +POST /users - создать нового пользователя +PUT /users - обновить существующего пользователя +``` + +## Обработка ошибок + +Приложение возвращает стандартизированные ошибки в формате: +```json +{ + "message": "Bad Request", + "status": 400, + "errors": { + "duration": "должно быть больше 0" + }, + "timestamp": "2025-10-11T15:45:29.7558387", + "path": "/films" +} +``` + +или в формате: + +```json +{ + "timestamp": "2025-10-11T12:46:21.315+00:00", + "status": 405, + "error": "Method Not Allowed", + "path": "/films" +} +``` diff --git a/application.properties b/application.properties new file mode 100644 index 0000000..f93a4cf --- /dev/null +++ b/application.properties @@ -0,0 +1,9 @@ +spring.application.name=Filmorate +server.port=8080 + +logging.file.path=./logs/ +logging.logback.rollingpolicy.file-name-pattern=./logs/spring.%d{yyyy-MM-dd}.%i.log +logging.logback.rollingpolicy.max-file-size=10MB +logging.logback.rollingpolicy.total-size-cap=50MB +logging.logback.rollingpolicy.clean-history-on-start=true +spring.output.ansi.enabled=ALWAYS \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0cad031..7e1d9e8 100644 --- a/pom.xml +++ b/pom.xml @@ -8,30 +8,48 @@ 3.2.4 + ru.yandex.practicum filmorate 0.0.1-SNAPSHOT + filmorate filmorate + 21 + 3.3.1 + org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-validation + + org.projectlombok lombok provided + - org.springframework.boot - spring-boot-starter-test + org.junit.jupiter + junit-jupiter test + @@ -40,6 +58,53 @@ org.springframework.boot spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.springframework.boot + spring-boot-configuration-processor + ${project.parent.version} + + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + true + true + true + checkstyle.xml + + + + + + check + + compile + + + + diff --git a/src/main/java/ru/yandex/practicum/filmorate/FilmorateApplication.java b/src/main/java/ru/yandex/practicum/filmorate/FilmorateApplication.java index dca451b..843905e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/FilmorateApplication.java +++ b/src/main/java/ru/yandex/practicum/filmorate/FilmorateApplication.java @@ -5,8 +5,8 @@ @SpringBootApplication public class FilmorateApplication { - public static void main(String[] args) { - SpringApplication.run(FilmorateApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(FilmorateApplication.class, args); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java index 08cf0a1..0936012 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -1,7 +1,89 @@ package ru.yandex.practicum.filmorate.controller; -import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.Film; + +import java.util.Collection; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicLong; @RestController +@RequestMapping("/films") +@Slf4j public class FilmController { + + private final HashMap films = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(); + + /** + * Private constructor to initialize ID generator with starting ID = 1. + */ + private FilmController() { + idGenerator.set(1); + } + + /** + * Handles GET method. + *

Retrieves all films from the storage. + * + * @return Collection of all films. + */ + @GetMapping + public Collection getFilms() { + log.info("GET /films - returning {} films", films.size()); + return films.values(); + } + + /** + * Handles POST method. + *

Creates film in storage after validation. + * + * @return created film. + */ + @PostMapping + public Film addFilm(@Valid @RequestBody Film film) { + + film.setId(idGenerator.getAndIncrement()); + films.put(film.getId(), film); + + log.info("POST /films - Film created: {}", film); + + return film; + } + + /** + * Handles PUT method. + *

Updates film in storage after validation. + * + *

Conditions: + *

+ * + * @return updated film. + * @throws ValidationException if ID is null or not valid + * @throws NotFoundException if film is not found + */ + @PutMapping + public Film updateFilm(@Valid @RequestBody Film newFilm) { + if (newFilm.getId() == null) { + throw new ValidationException("Id должен быть указан"); + } + + if (films.containsKey(newFilm.getId())) { + Film oldFilm = films.get(newFilm.getId()); + + films.put(newFilm.getId(), newFilm); + log.info("PUT /films - Film updated: {}", oldFilm); + + return newFilm; + } + + throw new NotFoundException("Фильм с id = " + newFilm.getId() + " не найден"); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java new file mode 100644 index 0000000..ba11211 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -0,0 +1,94 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.User; + +import java.util.Collection; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicLong; + +@RestController +@RequestMapping("/users") +@Slf4j +public class UserController { + + private final HashMap users = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(); + + /** + * Private constructor to initialize ID generator with starting ID = 1. + */ + private UserController() { + idGenerator.set(1); + } + + /** + * Handles GET method. + *

Retrieves all users from the storage. + * + * @return Collection of all users. + */ + @GetMapping + public Collection getUsers() { + log.info("GET /users - returning {} users", users.size()); + return users.values(); + } + + /** + * Handles POST method. + *

Creates user in storage after validation. + * + * @return created users. + */ + @PostMapping + public User addUser(@Valid @RequestBody User user) { + + user.setId(idGenerator.getAndIncrement()); + + if (user.getName() == null) { + user.setName(user.getLogin()); + } + + log.info("POST /users - User created: {}", user); + users.put(user.getId(), user); + + return user; + } + + /** + * Handles PUT method. + *

Updates user in storage after validation. + * + *

Conditions: + *

    + *
  • ID must be provided in the request body
  • + *
  • User with this ID should exist in the storage.
  • + *
+ * + * @return updated user. + * @throws ValidationException if ID is null or not valid + * @throws NotFoundException if user is not found + */ + @PutMapping + public User updateUser(@Valid @RequestBody User newUser) { + if (newUser.getId() == null) { + throw new ValidationException("Id должен быть указан"); + } + + if (users.containsKey(newUser.getId())) { + User oldUser = users.get(newUser.getId()); + + users.put(newUser.getId(), newUser); + + log.info("PUT /users - User updated: {}", oldUser); + + return newUser; + } + + throw new NotFoundException("Пользователь с id = " + newUser.getId() + " не найден"); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/ApiError.java b/src/main/java/ru/yandex/practicum/filmorate/exception/ApiError.java new file mode 100644 index 0000000..776f215 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ApiError.java @@ -0,0 +1,36 @@ +package ru.yandex.practicum.filmorate.exception; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Custom error response body for API exceptions. + * + *

Contains structured error information returned to clients when exceptions occur. + */ +@Data +public class ApiError { + private String message; + private int status; + private Map errors; + private LocalDateTime timestamp; + private String path; + + /** + * Creates a new API error response. + * + * @param message high-level error description + * @param status HTTP status code + * @param path request URI that caused the error + * @param errors detailed field-specific error messages + */ + public ApiError(String message, int status, String path, Map errors) { + this.timestamp = LocalDateTime.now(); + this.message = message; + this.status = status; + this.errors = errors; + this.path = path; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/GlobalExceptionHandler.java b/src/main/java/ru/yandex/practicum/filmorate/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b0d53b7 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/GlobalExceptionHandler.java @@ -0,0 +1,140 @@ +package ru.yandex.practicum.filmorate.exception; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.HashMap; +import java.util.Map; + +/** + * Global exception handler for REST controllers. + * Centralizes exception handling and provides consistent error responses. + */ +@ControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * Handles validation errors from @Valid annotated parameters. + * Returns field-specific error messages. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions( + MethodArgumentNotValidException ex, + HttpServletRequest request) { + + log.warn("Resolved: [{}]", ex.getClass().getName()); + log.debug("Validation error", ex); + Map errors = new HashMap<>(); + + + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return ResponseEntity.badRequest().body( + new ApiError("Bad Request", + HttpStatus.BAD_REQUEST.value(), + request.getRequestURI(), + errors) + ); + } + + /** + * Handles malformed JSON requests and invalid data formats. + * Provides specific parsing error details when available. + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpServletRequest request) { + + log.warn("Resolved: [{}]", ex.getClass().getName()); + log.debug("Invalid request format", ex); + + return ResponseEntity.badRequest().body( + new ApiError("Bad Request", + HttpStatus.BAD_REQUEST.value(), + request.getRequestURI(), + getErrors(ex) + ) + ); + } + + /** + * Helper for HttpMessageNotReadableException handler + * Extracts meaningful error messages from JSON parsing exceptions. + * + * @return map with error + */ + private static Map getErrors(HttpMessageNotReadableException ex) { + String message = "Invalid request format"; + + if (ex.getCause() instanceof JsonParseException jpe) { + message = String.format("%s at line: %d, column: %d", + jpe.getOriginalMessage(), + jpe.getLocation().getLineNr(), + jpe.getLocation().getColumnNr() + ); + } else if (ex.getCause() instanceof InvalidFormatException) { + message = "Invalid data format"; + } + + return Map.of("error", message); + } + + /** + * Handles business logic "not found" scenarios. + * Returns 404 status with descriptive message. + */ + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException( + NotFoundException ex, + HttpServletRequest request) { + + log.warn("Resolved: [{}]", ex.getClass().getName()); + log.debug("Not Found", ex); + Map errors = new HashMap<>(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body( + new ApiError("Not Found", + HttpStatus.NOT_FOUND.value(), + request.getRequestURI(), + Map.of("error", ex.getMessage()) + ) + ); + } + + /** + * Handles custom validation exceptions from service layer. + * Returns 400 status with business rule violation details. + */ + @ExceptionHandler(ValidationException.class) + public ResponseEntity handleValidationException( + ValidationException ex, + HttpServletRequest request) { + + log.warn("Resolved: [{}]", ex.getClass().getName()); + log.debug("Validation Error", ex); + + return ResponseEntity.badRequest().body( + new ApiError("Bad Request", + HttpStatus.BAD_REQUEST.value(), + request.getRequestURI(), + Map.of("error", ex.getMessage()) + + ) + ); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/NotFoundException.java b/src/main/java/ru/yandex/practicum/filmorate/exception/NotFoundException.java new file mode 100644 index 0000000..dd8d262 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/ValidationException.java b/src/main/java/ru/yandex/practicum/filmorate/exception/ValidationException.java new file mode 100644 index 0000000..52dc49c --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ValidationException.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.exception; + +public class ValidationException extends RuntimeException { + public ValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java index 3614a44..ada7009 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -1,12 +1,44 @@ package ru.yandex.practicum.filmorate.model; -import lombok.Getter; -import lombok.Setter; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.yandex.practicum.filmorate.validation.ValidReleaseDate; + +import java.time.LocalDate; /** - * Film. + * User. + *

DTO to represent user + * + *

Properties: + *

    + *
  • id - Unique identifier, automatically set by the service layer
  • + *
  • name - Film's name address, must not be empty
  • + *
  • description - Film's description, must not be over 200 characters
  • + *
  • releaseDate - Film`s release date, must be after 1985-01-28
  • + *
  • duration - Film's duration, must be positive
  • + *
*/ -@Getter -@Setter +@Data +@NoArgsConstructor +@AllArgsConstructor public class Film { + + private Long id; + + @NotBlank(message = "Название не может быть пустым") + private String name; + + @Size(max = 200, message = "Название фильма не может превышать 200 символов") + private String description; + + @ValidReleaseDate + private LocalDate releaseDate; + + @Positive + private Integer duration; } diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/User.java b/src/main/java/ru/yandex/practicum/filmorate/model/User.java new file mode 100644 index 0000000..ac664a5 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java @@ -0,0 +1,44 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.yandex.practicum.filmorate.validation.ValidBirthday; +import ru.yandex.practicum.filmorate.validation.ValidLogin; + +import java.time.LocalDate; + +/** + * User. + *

DTO to represent user + * + *

Properties: + *

    + *
  • id - Unique identifier, automatically set by the service layer
  • + *
  • email - User's email address, must be valid and not empty
  • + *
  • login - User's login identifier, must not be empty
  • + *
  • name - Display name; if null or blank, service will set it to login value
  • + *
  • birthday - User's birthdate, must be in the past or present
  • + *
+ */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + + private Long id; + + @NotBlank(message = "Почта не может быть пустой") + @Email + private String email; + + @ValidLogin + private String login; + + private String name; + + @ValidBirthday + private LocalDate birthday; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/BirthdayValidator.java b/src/main/java/ru/yandex/practicum/filmorate/validation/BirthdayValidator.java new file mode 100644 index 0000000..0dab21a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/BirthdayValidator.java @@ -0,0 +1,28 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.LocalDate; + +/** + * Custom validator for {@link ValidBirthday} + */ +public class BirthdayValidator implements ConstraintValidator { + + /** + * Validates that the birthday is not in the future. + * + * @param localDate the date to validate + * @param constraintValidatorContext validation context + * @return true if date is null or in past or present, false otherwise + */ + @Override + public boolean isValid(LocalDate localDate, ConstraintValidatorContext constraintValidatorContext) { + if (localDate == null) { + return true; + } + + return !localDate.isAfter(LocalDate.now()); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/LoginValidator.java b/src/main/java/ru/yandex/practicum/filmorate/validation/LoginValidator.java new file mode 100644 index 0000000..53e310a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/LoginValidator.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Custom validator for {@link ValidLogin} + */ +public class LoginValidator implements ConstraintValidator { + + /** + * Validates that the login is not null, blank, empty, or contains whitespaces. + * + * @param s the date to validate + * @param constraintValidatorContext validation context + * @return true if login is not null, blank, empty or contains whitespaces, false otherwise + */ + @Override + public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + + return s != null && !s.isBlank() && s.chars().noneMatch(Character::isWhitespace); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidator.java b/src/main/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidator.java new file mode 100644 index 0000000..da57745 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidator.java @@ -0,0 +1,30 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.LocalDate; + +/** + * Custom validator for {@link ValidReleaseDate} + */ +public class ReleaseDateValidator implements ConstraintValidator { + + private static final LocalDate MIN_ALLOWED_DATE = LocalDate.of(1895, 1, 28); + + /** + * Validates that the release date is not before 1895-01-28. + * + * @param localDate the date to validate + * @param constraintValidatorContext validation context + * @return true if date is null or after minimum allowed date, false otherwise + */ + @Override + public boolean isValid(LocalDate localDate, ConstraintValidatorContext constraintValidatorContext) { + if (localDate == null) { + return true; + } + + return !localDate.isBefore(MIN_ALLOWED_DATE); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/ValidBirthday.java b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidBirthday.java new file mode 100644 index 0000000..836e580 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidBirthday.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Validates that a birthdate is not in the future. + * + *

Applied to {@code LocalDate} fields to ensure the date represents a past or present date. + * Used primarily for user birthdate validation.

+ */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = BirthdayValidator.class) +public @interface ValidBirthday { + String message() default "Дата рождения не может быть в будущем"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/ValidLogin.java b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidLogin.java new file mode 100644 index 0000000..0b5b2fb --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidLogin.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Validates that a login has no whitespaces. + * + *

Applied to {@code String} fields to ensure the data is not null, blank and has not whitespaces + * Used primarily for user login validation.

+ */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = LoginValidator.class) +public @interface ValidLogin { + String message() default "Логин не может быть пустым и не должен содержать пробелы"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/validation/ValidReleaseDate.java b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidReleaseDate.java new file mode 100644 index 0000000..eda170e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/validation/ValidReleaseDate.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.filmorate.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Validates that a releaseDate is not before 1985-01-28. + * + *

Applied to {@code LocalDate} fields to validate data + * Used primarily for user Film`s releaseDate validation.

+ */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ReleaseDateValidator.class) +public @interface ValidReleaseDate { + String message() default "Дата релиза не может быть раньше 28 декабря 1895"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/ControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/ControllerTest.java new file mode 100644 index 0000000..e7b1e9f --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/ControllerTest.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class ControllerTest { + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + protected String json; + +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java new file mode 100644 index 0000000..75ad2aa --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java @@ -0,0 +1,143 @@ +package ru.yandex.practicum.filmorate.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import ru.yandex.practicum.filmorate.model.Film; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(FilmController.class) +public class FilmControllerTest extends ControllerTest { + + + private void addTestFilm() throws Exception { + mockMvc.perform(post("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + } + + @BeforeEach + public void getJsonString() throws JsonProcessingException { + + Film film = new Film(2L, + "TestFilm", + "TestDescription", + LocalDate.of(2000, 1, 1), + 120); + + json = objectMapper.writeValueAsString(film); + } + + @Nested + class FilmControllerGetTest { + + @Test + public void shouldGetFilms() throws Exception { + addTestFilm(); + addTestFilm(); + + mockMvc.perform(get("/films")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].name").value("TestFilm")) + .andExpect(jsonPath("$[1].id").value("2")) + .andExpect(jsonPath("$[1].name").value("TestFilm")); + } + } + + @Nested + class FilmControllerPostTest { + + @Test + public void shouldCreateFilm() throws Exception { + + mockMvc.perform(post("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.name").value("TestFilm")) + .andExpect(jsonPath("$.description").value("TestDescription")) + .andExpect(jsonPath("$.releaseDate").value("2000-01-01")) + .andExpect(jsonPath("$.duration").value("120")); + } + + @Test + public void shouldNotCreateFilmWhenInvalidJson() throws Exception { + String json = "{}"; + + mockMvc.perform(post("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + class FilmControllerPutTest { + + @BeforeEach + public void getNewText() throws JsonProcessingException { + Film film = new Film(1L, + "ONLY TODAY", + "NEW TEXT", + LocalDate.of(2000, 1, 1), + 120); + + json = objectMapper.writeValueAsString(film); + } + + @Test + public void shouldUpdateFilm() throws Exception { + addTestFilm(); + + mockMvc.perform(put("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("ONLY TODAY")) + .andExpect(jsonPath("$.description").value("NEW TEXT")) + .andExpect(jsonPath("$.releaseDate").value("2000-01-01")) + .andExpect(jsonPath("$.duration").value("120")); + } + + @Test + public void shouldNotUpdateFilmWhenNoId() throws Exception { + String json = + "{\"name\":\"test\", \"description\":\"test\", \"releaseDate\":\"2000-01-01\", \"name\":120}"; + addTestFilm(); + + + mockMvc.perform(put("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + public void shouldNotUpdateFilmWhenNotFound() throws Exception { + mockMvc.perform(put("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()); + } + + @Test + public void shouldNotUpdateFilmWhenInvalidJson() throws Exception { + String json = "{}"; + + mockMvc.perform(put("/films") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java new file mode 100644 index 0000000..d9b5cb5 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java @@ -0,0 +1,169 @@ +package ru.yandex.practicum.filmorate.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import ru.yandex.practicum.filmorate.model.User; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserController.class) +public class UserControllerTest extends ControllerTest { + + private void addTestUser() throws Exception { + mockMvc.perform(post("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + } + + @BeforeEach + public void getJsonString() throws JsonProcessingException { + + User user = new User(2L, + "valid@mail.com", + "login", + "George", + LocalDate.of(2000, 1, 1) + ); + + json = objectMapper.writeValueAsString(user); + + } + + @Nested + class UserGetTest { + + @Test + public void shouldGetUsers() throws Exception { + addTestUser(); + addTestUser(); + + mockMvc.perform(get("/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("1")) + .andExpect(jsonPath("$[0].email").value("valid@mail.com")) + .andExpect(jsonPath("$[1].id").value("2")) + .andExpect(jsonPath("$[1].email").value("valid@mail.com")); + } + } + + @Nested + class UserPostTest { + + @Test + public void shouldCreateUser() throws Exception { + + mockMvc.perform(post("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.email").value("valid@mail.com")) + .andExpect(jsonPath("$.login").value("login")) + .andExpect(jsonPath("$.name").value("George")) + .andExpect(jsonPath("$.birthday").value("2000-01-01")); + } + + @Test + public void shouldCreateUserAndNameShouldBeLoginIfNameNull() throws Exception { + + User user = new User(2L, + "valid@mail.com", + "login", + null, + LocalDate.of(2000, 1, 1) + ); + + json = objectMapper.writeValueAsString(user); + + mockMvc.perform(post("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.email").value("valid@mail.com")) + .andExpect(jsonPath("$.login").value("login")) + .andExpect(jsonPath("$.name").value("login")) + .andExpect(jsonPath("$.birthday").value("2000-01-01")); + } + + @Test + public void shouldNotCreateUserWhenInvalidJson() throws Exception { + String json = "{}"; + + mockMvc.perform(post("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + class UserPutTest { + + @BeforeEach + public void getNewText() throws JsonProcessingException { + User user = new User(2L, + "valid@mail.com", + "newlogin", + "same George", + LocalDate.of(2000, 1, 1) + ); + + json = objectMapper.writeValueAsString(user); + } + + @Test + public void shouldUpdateUser() throws Exception { + addTestUser(); + addTestUser(); + + mockMvc.perform(put("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("valid@mail.com")) + .andExpect(jsonPath("$.login").value("newlogin")) + .andExpect(jsonPath("$.name").value("same George")) + .andExpect(jsonPath("$.birthday").value("2000-01-01")); + } + + @Test + public void shouldNotUpdateUserWhenNoId() throws Exception { + String json = + "{\"email\":\"valid@mail.com\", \"login\":\"test\", \"name\":\"test\", \"birthday\":2000-01-01}"; + addTestUser(); + + + mockMvc.perform(put("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + public void shouldNotUpdateUserWhenNotFound() throws Exception { + mockMvc.perform(put("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()); + } + + @Test + public void shouldNotUpdateUserWhenInvalidJson() throws Exception { + String json = "{}"; + + mockMvc.perform(put("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/model/FilmTest.java b/src/test/java/ru/yandex/practicum/filmorate/model/FilmTest.java new file mode 100644 index 0000000..23ccb09 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/model/FilmTest.java @@ -0,0 +1,134 @@ +package ru.yandex.practicum.filmorate.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FilmTest extends ModelTest { + private Film film; + + @BeforeEach + public void initFilm() { + film = new Film(1L, "name", "description", LocalDate.now(), 10); + } + + @Nested + class FilmNameTest { + + @Test + public void shouldFindViolationWhenNameIsNull() { + film.setName(null); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldFindViolationWhenNameIsEmpty() { + film.setName(""); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldFindViolationWhenNameIsBlank() { + film.setName(" "); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenNameIsValid() { + film.setName("ihateunittests"); + assertEquals(0, validateModel(film)); + } + } + + @Nested + class FilmDescriptionTest { + + @Test + public void shouldFindViolationWhenDescriptionIsOver200chars() { + film.setDescription("a".repeat(250)); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIs200chars() { + film.setDescription("a".repeat(200)); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIsValid() { + film.setDescription("valid"); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIsNull() { + film.setDescription(null); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIsEmpty() { + film.setDescription(""); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIsBlank() { + film.setDescription(" "); + assertEquals(0, validateModel(film)); + } + } + + @Nested + class FilmReleaseDateTest { + @Test + public void shouldFindViolationWhenReleaseDateBefore() { + film.setReleaseDate(LocalDate.of(1000, 1, 1)); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenReleaseDateValid() { + film.setReleaseDate(LocalDate.now()); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDescriptionIsNull() { + film.setReleaseDate(null); + assertEquals(0, validateModel(film)); + } + } + + @Nested + class FilmDurationTest { + @Test + public void shouldFindViolationWhenDurationIsZero() { + film.setDuration(0); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldFindViolationWhenDurationIsNegative() { + film.setDuration(-1); + assertEquals(1, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDurationIsPositive() { + film.setDuration(1); + assertEquals(0, validateModel(film)); + } + + @Test + public void shouldNotFindViolationWhenDurationIsNull() { + film.setDuration(null); + assertEquals(0, validateModel(film)); + } + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/model/ModelTest.java b/src/test/java/ru/yandex/practicum/filmorate/model/ModelTest.java new file mode 100644 index 0000000..1d67029 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/model/ModelTest.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeAll; + +public class ModelTest { + + protected static Validator validator; + + @BeforeAll + public static void setValidator() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + protected int validateModel(T model) { + return validator.validate(model).size(); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/model/UserTest.java b/src/test/java/ru/yandex/practicum/filmorate/model/UserTest.java new file mode 100644 index 0000000..e9ef89b --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/model/UserTest.java @@ -0,0 +1,113 @@ +package ru.yandex.practicum.filmorate.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class UserTest extends ModelTest { + + private User user; + + @BeforeEach + public void initUser() { + user = new User(1L, + "valid@mail.com", + "login", "name", + LocalDate.of(2000, 1, 1)); + } + + @Nested + class UserEmailTest { + + @Test + public void shouldFindViolationWhenEmailIsNull() { + user.setEmail(null); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldFindViolationWhenEmailIsEmpty() { + user.setEmail(""); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldFindViolationWhenEmailIsBlank() { + user.setEmail(" "); + // 2 since it not valid email and blank + assertEquals(2, validateModel(user)); + } + + @Test + public void shouldFindViolationWhenEmailIsNotValid() { + user.setEmail("@email"); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldNotFindViolationWhenEmailIsValid() { + user.setEmail("valid@mail.com"); + assertEquals(0, validateModel(user)); + } + } + + @Nested + class UserLoginTest { + + @Test + public void shouldFindViolationWhenNameIsNull() { + user.setLogin(null); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldFindViolationWhenNameIsEmpty() { + user.setLogin(""); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldFindViolationWhenNameIsBlank() { + user.setLogin(" "); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldNotFindViolationWhenNameIsValid() { + user.setLogin("ihateunittests"); + assertEquals(0, validateModel(user)); + } + } + + @Nested + class UserBirthdayTest { + + @Test + public void shouldFindViolationWhenBirthdayIsFuture() { + user.setBirthday(LocalDate.now().plusDays(1)); + assertEquals(1, validateModel(user)); + } + + @Test + public void shouldNotFindViolationWhenBirthdayIsToday() { + user.setBirthday(LocalDate.now()); + assertEquals(0, validateModel(user)); + } + + @Test + public void shouldNotFindViolationWhenBirthdayIsValid() { + user.setBirthday(LocalDate.of(2000, 1, 1)); + assertEquals(0, validateModel(user)); + } + + @Test + public void shouldNotFindViolationWhenBirthdayIsNull() { + user.setBirthday(null); + assertEquals(0, validateModel(user)); + } + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/validation/BirthdayValidatorTest.java b/src/test/java/ru/yandex/practicum/filmorate/validation/BirthdayValidatorTest.java new file mode 100644 index 0000000..6dd425f --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/validation/BirthdayValidatorTest.java @@ -0,0 +1,35 @@ +package ru.yandex.practicum.filmorate.validation; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BirthdayValidatorTest { + private final BirthdayValidator validator = new BirthdayValidator(); + + @Test + public void shouldReturnTrueForValidDate() { + LocalDate birthday = LocalDate.of(1595, 1, 28); + assertTrue(validator.isValid(birthday, null)); + } + + @Test + public void shouldReturnTrueForToday() { + LocalDate future = LocalDate.now(); + assertTrue(validator.isValid(future, null)); + } + + @Test + public void shouldReturnFalseForFutureDate() { + LocalDate future = LocalDate.now().plusDays(1); + assertFalse(validator.isValid(future, null)); + } + + @Test + public void shouldReturnTrueForNull() { + assertTrue(validator.isValid(null, null)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/validation/LoginValidatorTest.java b/src/test/java/ru/yandex/practicum/filmorate/validation/LoginValidatorTest.java new file mode 100644 index 0000000..eb5b009 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/validation/LoginValidatorTest.java @@ -0,0 +1,42 @@ +package ru.yandex.practicum.filmorate.validation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LoginValidatorTest { + + private final LoginValidator validator = new LoginValidator(); + + @Test + public void shouldReturnTrueForValidLogin() { + String login = "login"; + assertTrue(validator.isValid(login, null)); + } + + @Test + public void shouldReturnFalseWhenLoginNull() { + assertFalse(validator.isValid(null, null)); + } + + @Test + public void shouldReturnFalseWhenLoginEmpty() { + assertFalse(validator.isValid("", null)); + } + + @Test + public void shouldReturnFalseWhenLoginBlank() { + assertFalse(validator.isValid(" ", null)); + } + + @Test + public void shouldReturnFalseWhenLoginContainsWhiteSpaces() { + assertFalse(validator.isValid("some login", null)); + } + + @Test + public void shouldReturnFalseWhenLoginContainsLotsOfWhiteSpaces() { + assertFalse(validator.isValid("some log in ", null)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidatorTest.java b/src/test/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidatorTest.java new file mode 100644 index 0000000..7ad8680 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/validation/ReleaseDateValidatorTest.java @@ -0,0 +1,29 @@ +package ru.yandex.practicum.filmorate.validation; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ReleaseDateValidatorTest { + private final ReleaseDateValidator validator = new ReleaseDateValidator(); + + @Test + public void shouldReturnTrueForValidDate() { + LocalDate releaseDate = LocalDate.now(); + assertTrue(validator.isValid(releaseDate, null)); + } + + @Test + public void shouldReturnFalseForImpossibleDate() { + LocalDate impossibleRelease = LocalDate.of(1595, 1, 28); + assertFalse(validator.isValid(impossibleRelease, null)); + } + + @Test + public void shouldReturnTrueForNull() { + assertTrue(validator.isValid(null, null)); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..947989f --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,3 @@ +logging.level.ru.yandex.practicum.filmorate=OFF +logging.level.org.springframework=WARN +spring.main.banner-mode=off \ No newline at end of file