diff --git a/pom.xml b/pom.xml index 0cad031..64175e2 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,15 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + @@ -40,6 +49,22 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + + checkstyle.xml + + + + + check + + compile + + + diff --git a/src/main/java/ru/yandex/practicum/filmorate/annotation/NotBeforeCinemaBirthday.java b/src/main/java/ru/yandex/practicum/filmorate/annotation/NotBeforeCinemaBirthday.java new file mode 100644 index 0000000..6bc785f --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/annotation/NotBeforeCinemaBirthday.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.annotation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import ru.yandex.practicum.filmorate.util.NotBeforeCinemaBirthdayValidator; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = NotBeforeCinemaBirthdayValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NotBeforeCinemaBirthday { + String message() default "The date cannot be earlier than the cinema's birthday — December 28, 1895"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} 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..3943efd 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,51 @@ package ru.yandex.practicum.filmorate.controller; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ru.yandex.practicum.filmorate.exception.FilmNotFoundException; +import ru.yandex.practicum.filmorate.model.Film; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j @RestController +@RequestMapping("/films") public class FilmController { + private final Map films = new HashMap<>(); + private long lastGeneratedID = 0; + + @PostMapping + public Film createFilm(@Valid @RequestBody Film film) { + film.setId(++lastGeneratedID); + films.put(film.getId(), film); + + log.info("Film created: id={}, name={}", film.getId(), film.getName()); + return film; + } + + @GetMapping + public List getAllFilms() { + log.debug("Retrieving all films (total={})", films.size()); + return new ArrayList<>(films.values()); + } + + @PutMapping + public Film updateFilm(@Valid @RequestBody Film film) { + if (!films.containsKey(film.getId())) { + log.warn("Attempt to update non-existent film id={}", film.getId()); + throw new FilmNotFoundException(); + } + films.put(film.getId(), film); + log.info("Film updated: id={}, name={}", film.getId(), film.getName()); + return film; + } } 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..4c7d4ff --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -0,0 +1,51 @@ +package ru.yandex.practicum.filmorate.controller; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import ru.yandex.practicum.filmorate.exception.UserNotFoundException; +import ru.yandex.practicum.filmorate.model.User; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/users") +public class UserController { + private final Map users = new HashMap<>(); + private long lastGeneratedID = 0; + + @PostMapping + public User createUser(@Valid @RequestBody User user) { + user.setId(++lastGeneratedID); + users.put(user.getId(), user); + + log.info("User created: id={}, email={}, login={}", user.getId(), user.getEmail(), user.getLogin()); + return user; + } + + @GetMapping + public List getAllUsers() { + log.debug("Retrieving all users (total={})", users.size()); + return new ArrayList<>(users.values()); + } + + @PutMapping + public User updateUser(@Valid @RequestBody User user) { + if (!users.containsKey(user.getId())) { + log.warn("Attempt to update non-existent user id={}", user.getId()); + throw new UserNotFoundException(); + } + users.put(user.getId(), user); + log.info("User updated: id={}, email={}, login={}", user.getId(), user.getEmail(), user.getLogin()); + return user; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/FilmNotFoundException.java b/src/main/java/ru/yandex/practicum/filmorate/exception/FilmNotFoundException.java new file mode 100644 index 0000000..b3564d4 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/FilmNotFoundException.java @@ -0,0 +1,15 @@ +package ru.yandex.practicum.filmorate.exception; + +public class FilmNotFoundException extends RuntimeException { + public FilmNotFoundException() { + super("Film not found"); + } + + public FilmNotFoundException(String message) { + super(message); + } + + public FilmNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} 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..b9ec44c --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package ru.yandex.practicum.filmorate.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidationException(ValidationException ex) { + log.error("Validation error: {}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", ex.getMessage()); + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFoundException(UserNotFoundException ex) { + log.warn("User not found: {}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", ex.getMessage() != null ? ex.getMessage() : "User not found"); + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(FilmNotFoundException.class) + public ResponseEntity> handleFilmNotFoundException(FilmNotFoundException ex) { + log.warn("Film not found: {}", ex.getMessage()); + Map body = new HashMap<>(); + body.put("error", ex.getMessage() != null ? ex.getMessage() : "Film not found"); + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + // Для любых неожиданных ошибок + @ExceptionHandler(Exception.class) + public ResponseEntity> handleOtherExceptions(Exception ex) { + log.error("Unexpected error", ex); + Map body = new HashMap<>(); + body.put("error", "Unexpected error: " + ex.getMessage()); + return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/UserNotFoundException.java b/src/main/java/ru/yandex/practicum/filmorate/exception/UserNotFoundException.java new file mode 100644 index 0000000..516482d --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/UserNotFoundException.java @@ -0,0 +1,15 @@ +package ru.yandex.practicum.filmorate.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException() { + super("User not found"); + } + + public UserNotFoundException(String message) { + super(message); + } + + public UserNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} 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..f5b1326 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ValidationException.java @@ -0,0 +1,11 @@ +package ru.yandex.practicum.filmorate.exception; + +public class ValidationException extends RuntimeException { + public ValidationException(String message) { + super(message); + } + + public ValidationException(String message, Throwable cause) { + super(message, cause); + } +} 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..b802b51 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,40 @@ package ru.yandex.practicum.filmorate.model; -import lombok.Getter; -import lombok.Setter; - -/** - * Film. - */ -@Getter -@Setter +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import jakarta.validation.ValidationException; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import ru.yandex.practicum.filmorate.annotation.NotBeforeCinemaBirthday; +import ru.yandex.practicum.filmorate.util.DurationMinutesDeserializer; +import ru.yandex.practicum.filmorate.util.DurationMinutesSerializer; + +import java.time.Duration; +import java.time.LocalDate; + +@Data public class Film { + + private Long id; + + @NotBlank(message = "The film name cannot be empty") + private String name; + + @Size(max = 200, message = "The film description must not exceed 200 characters") + private String description; + + @NotBeforeCinemaBirthday + private LocalDate releaseDate; + + @JsonSerialize(using = DurationMinutesSerializer.class) + @JsonDeserialize(using = DurationMinutesDeserializer.class) + private Duration duration; + + public void setDuration(Duration duration) { + if (duration == null || duration.isNegative() || duration.isZero()) { + throw new ValidationException("Film duration must be positive"); + } + this.duration = 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..420d935 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java @@ -0,0 +1,35 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PastOrPresent; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class User { + + private Long id; + + @NotBlank(message = "Email cannot be empty") + @Email(message = "Email must contain '@' and be valid") + private String email; + + @NotBlank(message = "Login cannot be empty") + @Pattern(regexp = "\\S+", message = "Login must not contain spaces") + private String login; + + private String name; + + @PastOrPresent(message = "Birthday cannot be in the future") + private LocalDate birthday; + + public void setLogin(String login) { + this.login = login; + if (this.name == null || this.name.isBlank()) { + this.name = login; + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesDeserializer.java b/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesDeserializer.java new file mode 100644 index 0000000..1089e0e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesDeserializer.java @@ -0,0 +1,21 @@ +package ru.yandex.practicum.filmorate.util; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.time.Duration; + +public class DurationMinutesDeserializer extends StdDeserializer { + + public DurationMinutesDeserializer() { + super(Duration.class); + } + + @Override + public Duration deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + long minutes = p.getLongValue(); + return Duration.ofMinutes(minutes); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesSerializer.java b/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesSerializer.java new file mode 100644 index 0000000..1abd1e8 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/util/DurationMinutesSerializer.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.util; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import java.io.IOException; +import java.time.Duration; + +public class DurationMinutesSerializer extends StdSerializer { + + public DurationMinutesSerializer() { + super(Duration.class); + } + + @Override + public void serialize(Duration duration, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeNumber(duration.toMinutes()); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/util/NotBeforeCinemaBirthdayValidator.java b/src/main/java/ru/yandex/practicum/filmorate/util/NotBeforeCinemaBirthdayValidator.java new file mode 100644 index 0000000..20bacd1 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/util/NotBeforeCinemaBirthdayValidator.java @@ -0,0 +1,17 @@ +package ru.yandex.practicum.filmorate.util; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import ru.yandex.practicum.filmorate.annotation.NotBeforeCinemaBirthday; + +import java.time.LocalDate; + +public class NotBeforeCinemaBirthdayValidator implements ConstraintValidator { + + private static final LocalDate CINEMA_BIRTHDAY = LocalDate.of(1895, 12, 28); + + @Override + public boolean isValid(LocalDate value, ConstraintValidatorContext context) { + return value != null && !value.isBefore(CINEMA_BIRTHDAY); + } +} 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..064907c --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java @@ -0,0 +1,75 @@ +package ru.yandex.practicum.filmorate.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.yandex.practicum.filmorate.exception.FilmNotFoundException; +import ru.yandex.practicum.filmorate.model.Film; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class FilmControllerTest { + + private FilmController filmController; + + @BeforeEach + void setUp() { + filmController = new FilmController(); + } + + @Test + void createFilm_shouldAssignIdAndStoreFilm() { + Film film = new Film(); + film.setName("Inception"); + + Film created = filmController.createFilm(film); + + assertThat(created.getId()).isOne(); + assertThat(created.getName()).isEqualTo("Inception"); + assertThat(filmController.getAllFilms()).containsExactly(created); + } + + @Test + void getAllFilms_shouldReturnAllCreatedFilms() { + Film f1 = new Film(); + f1.setName("Matrix"); + Film f2 = new Film(); + f2.setName("Interstellar"); + + filmController.createFilm(f1); + filmController.createFilm(f2); + + List films = filmController.getAllFilms(); + + assertThat(films).hasSize(2); + assertThat(films).extracting(Film::getName) + .containsExactlyInAnyOrder("Matrix", "Interstellar"); + } + + @Test + void updateFilm_shouldReplaceExistingFilm() { + Film film = new Film(); + film.setName("Old title"); + Film created = filmController.createFilm(film); + + Film updated = new Film(); + updated.setId(created.getId()); + updated.setName("New title"); + + Film result = filmController.updateFilm(updated); + + assertThat(result.getName()).isEqualTo("New title"); + assertThat(filmController.getAllFilms()).containsExactly(result); + } + + @Test + void updateFilm_shouldThrowIfFilmNotExists() { + Film nonExistent = new Film(); + nonExistent.setId(999L); + nonExistent.setName("Ghost film"); + + assertThatThrownBy(() -> filmController.updateFilm(nonExistent)) + .isInstanceOf(FilmNotFoundException.class); + } +} 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..aa3c2b4 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java @@ -0,0 +1,76 @@ +package ru.yandex.practicum.filmorate.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.yandex.practicum.filmorate.exception.UserNotFoundException; +import ru.yandex.practicum.filmorate.model.User; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class UserControllerTest { + + private UserController userController; + + @BeforeEach + void setUp() { + userController = new UserController(); + } + + @Test + void createUser_shouldAssignIdAndStoreUser() { + User user = new User(); + user.setName("Alice"); + + User created = userController.createUser(user); + + assertThat(created.getId()).isOne(); + assertThat(created.getName()).isEqualTo("Alice"); + assertThat(userController.getAllUsers()).containsExactly(created); + } + + @Test + void getAllUsers_shouldReturnAllCreatedUsers() { + User u1 = new User(); + u1.setName("Bob"); + User u2 = new User(); + u2.setName("Charlie"); + + userController.createUser(u1); + userController.createUser(u2); + + List users = userController.getAllUsers(); + + assertThat(users).hasSize(2); + assertThat(users) + .extracting(User::getName) + .containsExactlyInAnyOrder("Bob", "Charlie"); + } + + @Test + void updateTask_shouldReplaceExistingUser() { + User user = new User(); + user.setName("Old name"); + User created = userController.createUser(user); + + User updated = new User(); + updated.setId(created.getId()); + updated.setName("New name"); + + User result = userController.updateUser(updated); + + assertThat(result.getName()).isEqualTo("New name"); + assertThat(userController.getAllUsers()).containsExactly(result); + } + + @Test + void updateTask_shouldThrowIfUserNotExists() { + User ghost = new User(); + ghost.setId(999L); + ghost.setName("Ghost user"); + + assertThatThrownBy(() -> userController.updateUser(ghost)) + .isInstanceOf(UserNotFoundException.class); + } +} 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..1667e15 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/model/FilmTest.java @@ -0,0 +1,100 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.ValidationException; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FilmTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void beforeAll() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void afterAll() { + factory.close(); + } + + @Test + void validFilm_shouldHaveNoValidationErrors() { + Film film = new Film(); + film.setName("Inception"); + film.setDescription("A mind-bending thriller"); + film.setReleaseDate(LocalDate.of(2010, 7, 16)); + film.setDuration(Duration.ofMinutes(148)); + + Set> violations = validator.validate(film); + assertThat(violations).isEmpty(); + } + + @Test + void emptyName_shouldFailValidation() { + Film film = new Film(); + film.setName(""); + film.setDescription("Some description"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(Duration.ofMinutes(120)); + + Set> violations = validator.validate(film); + assertThat(violations).extracting("propertyPath") + .extracting(Object::toString) + .contains("name"); + } + + @Test + void longDescription_shouldFailValidation() { + Film film = new Film(); + film.setName("Film"); + film.setDescription("A".repeat(201)); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(Duration.ofMinutes(120)); + + Set> violations = validator.validate(film); + assertThat(violations).extracting("propertyPath") + .extracting(Object::toString) + .contains("description"); + } + + @Test + void releaseDateBefore1895_shouldFailValidation() { + Film film = new Film(); + film.setName("Old film"); + film.setDescription("Description"); + film.setReleaseDate(LocalDate.of(1800, 1, 1)); + + Set> violations = validator.validate(film); + + assertThat(violations).isNotEmpty(); + assertThat(violations.iterator().next().getMessage()) + .contains("1895"); + } + + @Test + void negativeDuration_shouldThrowValidationException() { + Film film = new Film(); + film.setName("Movie"); + film.setDescription("Desc"); + + assertThatThrownBy(() -> + film.setDuration(Duration.ofMinutes(-100))) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("must be positive"); + } +} 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..bf880ba --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/model/UserTest.java @@ -0,0 +1,90 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void beforeAll() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void afterAll() { + factory.close(); + } + + @Test + void validUser_shouldHaveNoValidationErrors() { + User user = new User(); + user.setLogin("alice123"); + user.setEmail("alice@example.com"); + user.setName("Alice"); + user.setBirthday(LocalDate.of(1990, 1, 1)); + + Set> violations = validator.validate(user); + assertThat(violations).isEmpty(); + } + + @Test + void emptyEmail_shouldFailValidation() { + User user = new User(); + user.setLogin("alice"); + user.setEmail(""); + user.setBirthday(LocalDate.of(1990, 1, 1)); + + Set> violations = validator.validate(user); + assertThat(violations).extracting("propertyPath") + .extracting(Object::toString) + .contains("email"); + } + + @Test + void loginWithSpaces_shouldFailValidation() { + User user = new User(); + user.setLogin("alice 123"); + user.setEmail("alice@example.com"); + + Set> violations = validator.validate(user); + assertThat(violations).extracting("propertyPath") + .extracting(Object::toString) + .contains("login"); + } + + @Test + void birthdayInFuture_shouldFailValidation() { + User user = new User(); + user.setLogin("bob"); + user.setEmail("bob@example.com"); + user.setBirthday(LocalDate.now().plusDays(1)); + + Set> violations = validator.validate(user); + assertThat(violations).extracting("propertyPath") + .extracting(Object::toString) + .contains("birthday"); + } + + @Test + void emptyName_shouldUseLogin() { + User user = new User(); + user.setLogin("charlie"); + user.setEmail("charlie@example.com"); + + assertThat(user.getName()).isEqualTo(user.getLogin()); + } +}