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:
+ *
+ * - ID must be provided in the request body
+ * - Film with this ID should exist in the storage.
+ *
+ *
+ * @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 extends Payload>[] 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 extends Payload>[] 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 extends Payload>[] 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