diff --git a/README.md b/README.md index d74e832..ccf08e1 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,40 @@ # java-filmorate -Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, оценками пользователей и возврата топ-5 фильмов, рекомендованных к просмотру. +Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, +оценками пользователей и возврата топ фильмов, рекомендованных к просмотру. -Проект развивается поэтапно. На текущем этапе реализован базовый каркас Spring Boot приложения, REST API для управления пользователями и фильмами, in-memory хранилище и строгая валидация данных. +Проект развивается поэтапно. На текущем этапе реализованы слоистая архитектура +(controller → service → storage), социальные функции (друзья, лайки) +и полноценный REST API. ## Автор **Ксения Пылькина** *(Ksenia Pylkina)* ## Ключевые возможности на данном этапе -- CRUD-операции для `Film` и `User` (создание, обновление, получение списка). +- CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком). - Хранение данных в оперативной памяти (`HashMap`). -- Автоматическая генерация уникальных идентификаторов. -- Строгая валидация входящих данных через `spring-boot-starter-validation` (`@Valid`, `@NotBlank`, `@Email`, `@Positive` и др.). -- Кастомная бизнес-валидация (проверка даты релиза кино, запрет пробелов в логине, автозамена пустого имени на логин). -- Обработка ошибок через кастомное исключение `ValidationException`. -- Логирование всех ключевых операций и ошибок с помощью `SLF4J`. -- Покрытие бизнес-логики и граничных условий unit-тестами. +- Социальные функции: добавление/удаление друзей, список общих друзей. +- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. +- Слоистая архитектура: контроллер → сервис → хранилище. +- Внедрение зависимостей через конструктор (`@Autowired`). +- Строгая валидация входящих данных через `spring-boot-starter-validation`. +- Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин). +- Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500. +- Логирование HTTP-запросов и ответов через Logbook. +- Покрытие бизнес-логики unit-тестами. ## Технологии - **Java 21** - **Spring Boot 3.2.2** - `spring-boot-starter-web` (REST API) - - `spring-boot-starter-validation` (Валидация данных) -- **Lombok** (Генерация boilerplate-кода и логгеров) -- **JUnit 5** (Unit-тестирование) -- **Maven** (Сборка проекта и управление зависимостями) -- **Checkstyle** (Контроль качества и стиля кода) + - `spring-boot-starter-validation` (валидация данных) +- **Logbook 3.7.2** (логирование HTTP-запросов) +- **Lombok** (генерация boilerplate-кода и логгеров) +- **JUnit 5** (unit-тестирование) +- **Maven** (сборка проекта) +- **Checkstyle** (контроль стиля кода) ## Запуск проекта @@ -38,40 +45,66 @@ После запуска сервер будет доступен на порту **8080**. -Для проверки HTTP-запросов можно использовать **Postman** (в корне проекта или в материалах курса доступна готовая коллекция тестов `postman.json`). +Для проверки запросов можно использовать **Postman** +(коллекция `postman.json` доступна в материалах курса). ## Основные HTTP-эндпоинты ### Фильмы (`/films`) - `GET /films` — получить список всех фильмов. +- `GET /films/{id}` — получить фильм по ID. - `POST /films` — добавить новый фильм. - `PUT /films` — обновить существующий фильм. +- `PUT /films/{id}/like/{userId}` — поставить лайк фильму. +- `DELETE /films/{id}/like/{userId}` — убрать лайк. +- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10). ### Пользователи (`/users`) - `GET /users` — получить список всех пользователей. +- `GET /users/{id}` — получить пользователя по ID. - `POST /users` — создать нового пользователя. -- `PUT /users` — обновить данные существующего пользователя. +- `PUT /users` — обновить данные пользователя. +- `PUT /users/{id}/friends/{friendId}` — добавить в друзья. +- `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. +- `GET /users/{id}/friends` — список друзей пользователя. +- `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. ## Реализация по этапам -
+
Этап 1 — Каркас Spring Boot приложения и базовый REST API -На этом этапе был заложен фундамент будущего сервиса рекомендаций фильмов. Проект переведен на рельсы Spring Boot, реализованы базовые модели данных и контроллеры. +На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot, +реализованы базовые модели данных и контроллеры. **Что сделано:** -- Настроен `pom.xml`, подключены необходимые стартеры Spring Boot, Lombok и плагин Checkstyle. -- Созданы модели данных `Film` и `User` с использованием аннотаций Lombok (`@Data`). +- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle. +- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`). - Реализованы REST-контроллеры `FilmController` и `UserController`. -- Настроено in-memory хранение данных с ручной генерацией ID. -- Добавлена валидация данных через аннотации `jakarta.validation.constraints`. -- Реализована сложная бизнес-валидация (например, дата релиза фильма не может быть раньше 28 декабря 1895 года). -- Настроено логирование запросов и ошибок. -- Написаны unit-тесты для проверки логики валидации контроллеров. - -**Ключевые решения:** -- Использование `spring-boot-starter-validation` позволило значительно сократить количество ручных проверок `if-else` в контроллерах и сделать код чище. -- Выделение кастомного исключения `ValidationException` подготовило почву для будущего создания глобального обработчика ошибок (`@ControllerAdvice`). -- Разделение логики валидации и сохранения данных внутри контроллеров делает код читаемым и легко тестируемым. +- In-memory хранение данных с ручной генерацией ID. +- Валидация через аннотации `jakarta.validation.constraints`. +- Кастомная бизнес-валидация (дата релиза, пробелы в логине). +- Логирование запросов и ошибок. +- Unit-тесты для проверки логики валидации. + +
+ +
+Этап 2 — Слоистая архитектура, социальные функции, полный REST + +На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки, +API доведён до соответствия REST. + +**Что сделано:** +- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации + `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`). +- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`) + с бизнес-логикой лайков и друзей. +- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор. +- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем. +- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID). +- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500. +- Подключён Logbook для логирования HTTP-запросов и ответов. +- Unit-тесты обновлены под новую архитектуру.
\ No newline at end of file diff --git a/pom.xml b/pom.xml index 091c1d5..d6903f7 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,12 @@ spring-boot-starter-test test + + + org.zalando + logbook-spring-boot-starter + 3.7.2 + 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 b94fd78..21c3dda 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -2,73 +2,65 @@ import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.service.FilmService; -import java.time.LocalDate; import java.util.Collection; -import java.util.HashMap; -import java.util.Map; @RestController @RequestMapping("/films") @Slf4j public class FilmController { - // Хранилище фильмов в памяти - private final Map films = new HashMap<>(); + private final FilmService filmService; - private long currentId = 1; - - // Константа: дата первого релиза (день рождения кино) - private static final LocalDate CINEMA_BIRTHDAY = LocalDate.of(1895, 12, 28); + @Autowired + public FilmController(FilmService filmService) { + this.filmService = filmService; + } @GetMapping public Collection findAll() { - log.info("Получен запрос на получение списка всех фильмов"); - return films.values(); + log.info("GET /films"); + return filmService.findAll(); + } + + @GetMapping("/{id}") + public Film findById(@PathVariable long id) { + log.info("GET /films/{}", id); + return filmService.findById(id); } @PostMapping public Film create(@Valid @RequestBody Film film) { - log.info("Получен запрос на создание фильма: {}", film); - - validateFilm(film); - - film.setId(getNextId()); - films.put(film.getId(), film); - - log.info("Фильм успешно добавлен. Присвоен ID: {}", film.getId()); - return film; + log.info("POST /films : {}", film.getName()); + return filmService.add(film); } @PutMapping public Film update(@Valid @RequestBody Film film) { - log.info("Получен запрос на обновление фильма с ID: {}", film.getId()); - - if (film.getId() == null || !films.containsKey(film.getId())) { - log.error("Ошибка обновления: фильм с ID {} не найден", film.getId()); - throw new ValidationException("Фильм с таким ID не найден"); - } - - validateFilm(film); + log.info("PUT /films : id={}", film.getId()); + return filmService.update(film); + } - films.put(film.getId(), film); - log.info("Фильм с ID {} успешно обновлен", film.getId()); - return film; + @PutMapping("/{id}/like/{userId}") + public Film addLike(@PathVariable long id, @PathVariable long userId) { + log.info("PUT /films/{}/like/{}", id, userId); + return filmService.addLike(id, userId); } - // Вспомогательный метод для валидации - private void validateFilm(Film film) { - if (film.getReleaseDate().isBefore(CINEMA_BIRTHDAY)) { - log.error("Ошибка валидации: дата релиза {} раньше 28 декабря 1895 года", film.getReleaseDate()); - throw new ValidationException("Дата релиза фильма не может быть раньше 28 декабря 1895 года"); - } + @DeleteMapping("/{id}/like/{userId}") + public Film removeLike(@PathVariable long id, @PathVariable long userId) { + log.info("DELETE /films/{}/like/{}", id, userId); + return filmService.removeLike(id, userId); } - // Вспомогательный метод генерации id - private long getNextId() { - return currentId++; + @GetMapping("/popular") + public Collection getPopular( + @RequestParam(defaultValue = "10") int count) { + log.info("GET /films/popular?count={}", count); + return filmService.getPopular(count); } -} +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java index f7ca087..ba0d0ec 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -2,76 +2,72 @@ import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.service.UserService; import java.util.Collection; -import java.util.HashMap; -import java.util.Map; @RestController @RequestMapping("/users") @Slf4j public class UserController { - // Хранилище пользователей в памяти - private final Map users = new HashMap<>(); + private final UserService userService; - private long currentId = 1; + @Autowired + public UserController(UserService userService) { + this.userService = userService; + } @GetMapping public Collection findAll() { - log.info("Получен запрос на получение списка всех пользователей"); - return users.values(); + log.info("GET /users"); + return userService.findAll(); + } + + @GetMapping("/{id}") + public User findById(@PathVariable long id) { + log.info("GET /users/{}", id); + return userService.findById(id); } @PostMapping public User create(@Valid @RequestBody User user) { - log.info("Получен запрос на создание пользователя: {}", user.getLogin()); - - validateUser(user); - - user.setId(getNextId()); - users.put(user.getId(), user); - - log.info("Пользователь успешно добавлен. Присвоен ID: {}", user.getId()); - return user; + log.info("POST /users : {}", user.getLogin()); + return userService.add(user); } @PutMapping public User update(@Valid @RequestBody User user) { - log.info("Получен запрос на обновление пользователя с ID: {}", user.getId()); - - if (user.getId() == null || !users.containsKey(user.getId())) { - log.error("Ошибка обновления: пользователь с ID {} не найден", user.getId()); - throw new ValidationException("Пользователь с таким ID не найден"); - } - - validateUser(user); + log.info("PUT /users : id={}", user.getId()); + return userService.update(user); + } - users.put(user.getId(), user); - log.info("Пользователь с ID {} успешно обновлен", user.getId()); - return user; + @PutMapping("/{id}/friends/{friendId}") + public User addFriend(@PathVariable long id, @PathVariable long friendId) { + log.info("PUT /users/{}/friends/{}", id, friendId); + return userService.addFriend(id, friendId); } - // Вспомогательный метод для валидации и бизнес-логики - private void validateUser(User user) { - // Проверка на пробелы в логине - if (user.getLogin().contains(" ")) { - log.error("Ошибка валидации: логин '{}' содержит пробелы", user.getLogin()); - throw new ValidationException("Логин не может содержать пробелы"); - } + @DeleteMapping("/{id}/friends/{friendId}") + public User removeFriend(@PathVariable long id, @PathVariable long friendId) { + log.info("DELETE /users/{}/friends/{}", id, friendId); + return userService.removeFriend(id, friendId); + } - // Если имя пустое = использование логина - if (user.getName() == null || user.getName().isBlank()) { - log.info("Имя пользователя не указано. Используем логин: {}", user.getLogin()); - user.setName(user.getLogin()); - } + @GetMapping("/{id}/friends") + public Collection getFriends(@PathVariable long id) { + log.info("GET /users/{}/friends", id); + return userService.getFriends(id); } - // Вспомогательный метод для генерации ID - private long getNextId() { - return currentId++; + @GetMapping("/{id}/friends/common/{otherId}") + public Collection getCommonFriends( + @PathVariable long id, + @PathVariable long otherId) { + log.info("GET /users/{}/friends/common/{}", id, otherId); + return userService.getCommonFriends(id, otherId); } -} +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java new file mode 100644 index 0000000..a7128b6 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java @@ -0,0 +1,44 @@ +package ru.yandex.practicum.filmorate.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +@Slf4j +public class ErrorHandler { + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleValidationException(ValidationException e) { + log.warn("400: {}", e.getMessage()); + return Map.of("error", e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_FOUND) + public Map handleNotFoundException(NotFoundException e) { + log.warn("404: {}", e.getMessage()); + return Map.of("error", e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + log.warn("400: {}", e.getMessage()); + return Map.of("error", e.getMessage()); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Map handleException(Exception e) { + log.error("500: {}", e.getMessage(), e); + return Map.of("error", e.getMessage()); + } +} \ No newline at end of file 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..82e3f72 --- /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); + } +} \ No newline at end of file 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 6b01356..97f7679 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -7,6 +7,8 @@ import lombok.Data; import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; /** * Film. @@ -27,4 +29,14 @@ public class Film { @NotNull(message = "Продолжительность фильма не может быть пустой") @Positive(message = "Продолжительность фильма должна быть положительной") private Integer duration; + + private Set likes = new HashSet<>(); + + public void addLike(Long userId) { + likes.add(userId); + } + + public boolean removeLike(Long userId) { + return likes.remove(userId); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/User.java b/src/main/java/ru/yandex/practicum/filmorate/model/User.java index 33003b4..11d905c 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/User.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java @@ -7,6 +7,8 @@ import lombok.Data; import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; @Data public class User { @@ -24,4 +26,14 @@ public class User { @NotNull(message = "Дата рождения не может быть пустой") @PastOrPresent(message = "Дата рождения не может быть в будущем") private LocalDate birthday; + + private Set friends = new HashSet<>(); + + public void addFriend(Long friendId) { + friends.add(friendId); + } + + public boolean removeFriend(Long friendId) { + return friends.remove(friendId); + } } diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java new file mode 100644 index 0000000..c1ee020 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -0,0 +1,107 @@ +package ru.yandex.practicum.filmorate.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.storage.film.FilmStorage; +import ru.yandex.practicum.filmorate.storage.user.UserStorage; + +import java.time.LocalDate; +import java.util.Collection; + +@Service +@Slf4j +public class FilmService { + + // Константа: дата первого релиза (день рождения кино) + private static final LocalDate CINEMA_BIRTHDAY = LocalDate.of(1895, 12, 28); + + private final FilmStorage filmStorage; + // Нужен для проверки существования пользователя, который ставит лайк + private final UserStorage userStorage; + + @Autowired + public FilmService(FilmStorage filmStorage, UserStorage userStorage) { + this.filmStorage = filmStorage; + this.userStorage = userStorage; + } + + // Получение фильма по id с проверкой существования + private Film getFilmOrThrow(long id) { + return filmStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден")); + } + + // Получение пользователя по id с проверкой существования + private void getUserOrThrow(long id) { + userStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + } + + public Film addLike(long filmId, long userId) { + Film film = getFilmOrThrow(filmId); + getUserOrThrow(userId); // проверка существования пользователя — иначе 404 + + film.addLike(userId); + filmStorage.update(film); + log.debug("Пользователь id={} поставил лайк фильму id={}", userId, filmId); + return film; + } + + public Film removeLike(long filmId, long userId) { + Film film = getFilmOrThrow(filmId); + getUserOrThrow(userId); // проверка существования пользователя — иначе 404 + + if (!film.removeLike(userId)) { + throw new NotFoundException( + "Пользователь id=" + userId + " не ставил лайк фильму id=" + filmId + ); + } + + filmStorage.update(film); + log.debug("Пользователь id={} убрал лайк с фильма id={}", userId, filmId); + return film; + } + + public Collection getPopular(int count) { + // Валидация: count должен быть положительным числом + if (count <= 0) { + throw new ValidationException( + "Количество фильмов должно быть положительным числом, получено: " + count + ); + } + return filmStorage.findPopular(count); + } + + // Валидация даты релиза — не может быть раньше дня рождения кино + private void validateReleaseDate(Film film) { + if (film.getReleaseDate().isBefore(CINEMA_BIRTHDAY)) { + log.warn("Некорректная дата релиза: {}", film.getReleaseDate()); + throw new ValidationException( + "Дата релиза фильма не может быть раньше 28 декабря 1895 года" + ); + } + } + + public Film add(Film film) { + validateReleaseDate(film); + return filmStorage.add(film); + } + + public Film update(Film film) { + getFilmOrThrow(film.getId()); // проверка существования — иначе 404 + validateReleaseDate(film); + return filmStorage.update(film); + } + + public Collection findAll() { + return filmStorage.findAll(); + } + + public Film findById(long id) { + return getFilmOrThrow(id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java new file mode 100644 index 0000000..fb95354 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -0,0 +1,130 @@ +package ru.yandex.practicum.filmorate.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.user.UserStorage; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +@Service +@Slf4j +public class UserService { + + private final UserStorage userStorage; + + @Autowired + public UserService(UserStorage userStorage) { + this.userStorage = userStorage; + } + + private void validateDifferentUsers(long userId, long otherId) { + if (userId == otherId) { + throw new ValidationException( + "Идентификаторы пользователей должны различаться, получено: id=" + userId + ); + } + } + + // Получение пользователя по id с проверкой существования + private User getUserOrThrow(long id) { + return userStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + } + + public User addFriend(long userId, long friendId) { + validateDifferentUsers(userId, friendId); + + User user = getUserOrThrow(userId); + User friend = getUserOrThrow(friendId); + + user.addFriend(friendId); + friend.addFriend(userId); + + log.debug("Пользователи id={} и id={} теперь друзья", userId, friendId); + return user; + } + + public User removeFriend(long userId, long friendId) { + validateDifferentUsers(userId, friendId); + + User user = getUserOrThrow(userId); + User friend = getUserOrThrow(friendId); + + user.removeFriend(friendId); + friend.removeFriend(userId); + + log.debug("Пользователи id={} и id={} больше не друзья", userId, friendId); + return user; + } + + public Collection getFriends(long userId) { + User user = getUserOrThrow(userId); + List friends = new ArrayList<>(); + + for (Long friendId : user.getFriends()) { + friends.add(getUserOrThrow(friendId)); + } + + return friends; + } + + public Collection getCommonFriends(long userId, long otherId) { + validateDifferentUsers(userId, otherId); + + User user = getUserOrThrow(userId); + User other = getUserOrThrow(otherId); + List common = new ArrayList<>(); + + for (Long friendId : user.getFriends()) { + if (other.getFriends().contains(friendId)) { + common.add(getUserOrThrow(friendId)); + } + } + + return common; + } + + // Логин не может содержать пробелы + private void validateLogin(User user) { + if (user.getLogin().contains(" ")) { + log.warn("Некорректный логин: '{}'", user.getLogin()); + throw new ValidationException("Логин не может содержать пробелы"); + } + } + + private void applyDefaultName(User user) { + if (user.getName() == null || user.getName().isBlank()) { + user.setName(user.getLogin()); + log.debug("Имя пользователя не задано — используется логин: '{}'", user.getLogin()); + } + } + + // Делегирование базовых операций хранилищу + public User add(User user) { + validateLogin(user); + applyDefaultName(user); + return userStorage.add(user); + } + + public User update(User user) { + getUserOrThrow(user.getId()); + + validateLogin(user); + applyDefaultName(user); + return userStorage.update(user); + } + + public Collection findAll() { + return userStorage.findAll(); + } + + public User findById(long id) { + return getUserOrThrow(id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java new file mode 100644 index 0000000..2d5eff2 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -0,0 +1,22 @@ +package ru.yandex.practicum.filmorate.storage.film; + +import ru.yandex.practicum.filmorate.model.Film; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FilmStorage { + + Film add(Film film); + + Film update(Film film); + + Film delete(long id); + + Collection findAll(); + + Optional findById(long id); + + List findPopular(int count); +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java new file mode 100644 index 0000000..9d9dc97 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/InMemoryFilmStorage.java @@ -0,0 +1,66 @@ +package ru.yandex.practicum.filmorate.storage.film; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Film; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +@Slf4j +public class InMemoryFilmStorage implements FilmStorage { + + // Хранилище фильмов в памяти + private final Map films = new HashMap<>(); + private long currentId = 1; + + @Override + public Film add(Film film) { + film.setId(currentId++); + films.put(film.getId(), film); + log.debug("Добавлен фильм: id={}, name={}", film.getId(), film.getName()); + return film; + } + + @Override + public Film update(Film film) { + + films.put(film.getId(), film); + log.debug("Обновлён фильм: id={}", film.getId()); + return film; + } + + @Override + public Film delete(long id) { + Film film = findById(id) + .orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден")); + + films.remove(id); + log.debug("Удалён фильм: id={}", id); + return film; + } + + @Override + public Collection findAll() { + return films.values(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(films.get(id)); + } + + @Override + public List findPopular(int count) { + // Сортировка по убыванию лайков, берём первые count штук + return films.values().stream() + .sorted((f1, f2) -> f2.getLikes().size() - f1.getLikes().size()) + .limit(count) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java new file mode 100644 index 0000000..e9106a5 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/InMemoryUserStorage.java @@ -0,0 +1,55 @@ +package ru.yandex.practicum.filmorate.storage.user; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.User; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Component +@Slf4j +public class InMemoryUserStorage implements UserStorage { + + private final Map users = new HashMap<>(); + private long currentId = 1; + + @Override + public User add(User user) { + user.setId(currentId++); + users.put(user.getId(), user); + log.debug("Добавлен пользователь: id={}, login={}", user.getId(), user.getLogin()); + return user; + } + + @Override + public User update(User user) { + + users.put(user.getId(), user); + log.debug("Обновлён пользователь: id={}", user.getId()); + return user; + } + + @Override + public User delete(long id) { + User user = findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + + users.remove(id); + log.debug("Удалён пользователь: id={}", id); + return user; + } + + @Override + public Collection findAll() { + return users.values(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(users.get(id)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java new file mode 100644 index 0000000..5c2c298 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/user/UserStorage.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.storage.user; + +import ru.yandex.practicum.filmorate.model.User; + +import java.util.Collection; +import java.util.Optional; + +public interface UserStorage { + + User add(User user); + + User update(User user); + + User delete(long id); + + Collection findAll(); + + Optional findById(long id); +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b13789..21af5c7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1 @@ - +logging.level.org.zalando.logbook=TRACE diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java index 032e3b4..2164488 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/FilmControllerTest.java @@ -4,6 +4,9 @@ import org.junit.jupiter.api.Test; import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.service.FilmService; +import ru.yandex.practicum.filmorate.storage.film.InMemoryFilmStorage; +import ru.yandex.practicum.filmorate.storage.user.InMemoryUserStorage; import java.time.LocalDate; @@ -14,8 +17,11 @@ class FilmControllerTest { @BeforeEach void setUp() { - // Перед каждым тестом создаётся новый чистый контроллер - filmController = new FilmController(); + // Перед каждым тестом создаётся новая цепочка зависимостей + InMemoryFilmStorage filmStorage = new InMemoryFilmStorage(); + InMemoryUserStorage userStorage = new InMemoryUserStorage(); + FilmService filmService = new FilmService(filmStorage, userStorage); + filmController = new FilmController(filmService); } @Test diff --git a/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java b/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java index 3364305..5e41289 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/controller/UserControllerTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import ru.yandex.practicum.filmorate.exception.ValidationException; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.service.UserService; +import ru.yandex.practicum.filmorate.storage.user.InMemoryUserStorage; import java.time.LocalDate; @@ -15,8 +17,10 @@ class UserControllerTest { @BeforeEach void setUp() { - // Перед каждым тестом создаётся новый чистый контроллер - userController = new UserController(); + // Перед каждым тестом создаётся новая цепочка зависимостей + InMemoryUserStorage userStorage = new InMemoryUserStorage(); + UserService userService = new UserService(userStorage); + userController = new UserController(userService); } @Test diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java new file mode 100644 index 0000000..d953f85 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/service/FilmServiceTest.java @@ -0,0 +1,129 @@ +package ru.yandex.practicum.filmorate.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.film.InMemoryFilmStorage; +import ru.yandex.practicum.filmorate.storage.user.InMemoryUserStorage; + +import java.time.LocalDate; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +class FilmServiceTest { + + private FilmService filmService; + private UserService userService; + + @BeforeEach + void setUp() { + InMemoryUserStorage userStorage = new InMemoryUserStorage(); + userService = new UserService(userStorage); + filmService = new FilmService(new InMemoryFilmStorage(), userStorage); + } + + // Вспомогательные методы - отмена дублирования кода создания объектов в каждом тесте + private Film createFilm(String name) { + Film film = new Film(); + film.setName(name); + film.setDescription("Описание"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(120); + return filmService.add(film); + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setName(login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userService.add(user); + } + + @Test + void shouldAddFilm() { + Film film = createFilm("Inception"); + assertNotNull(film.getId()); + } + + @Test + void shouldThrowWhenReleaseDateTooOld() { + Film film = new Film(); + film.setName("Old Movie"); + film.setDescription("Очень старый"); + film.setReleaseDate(LocalDate.of(1895, 12, 27)); + film.setDuration(100); + + assertThrows(ValidationException.class, () -> filmService.add(film)); + } + + @Test + void shouldAddAndRemoveLike() { + Film film = createFilm("Inception"); + User user = createUser("userOne"); + + filmService.addLike(film.getId(), user.getId()); + assertEquals(1, filmService.findById(film.getId()).getLikes().size()); + + filmService.removeLike(film.getId(), user.getId()); + assertEquals(0, filmService.findById(film.getId()).getLikes().size()); + } + + @Test + void shouldThrowWhenAddLikeFromNonExistentUser() { + Film film = createFilm("Inception"); + assertThrows(NotFoundException.class, () -> filmService.addLike(film.getId(), 999L)); + } + + @Test + void shouldThrowWhenRemoveLikeNotExist() { + Film film = createFilm("Inception"); + User user = createUser("userOne"); + + assertThrows(NotFoundException.class, + () -> filmService.removeLike(film.getId(), user.getId())); + } + + @Test + void shouldReturnPopularFilms() { + Film film1 = createFilm("Film1"); + Film film2 = createFilm("Film2"); + Film film3 = createFilm("Film3"); + + User user1 = createUser("user1"); + User user2 = createUser("user2"); + + // film2 получает 2 лайка, film1 — 1, film3 — 0 + filmService.addLike(film2.getId(), user1.getId()); + filmService.addLike(film2.getId(), user2.getId()); + filmService.addLike(film1.getId(), user1.getId()); + + Collection popular = filmService.getPopular(3); + Film[] films = popular.toArray(new Film[0]); + + assertEquals(film2.getId(), films[0].getId()); + assertEquals(film1.getId(), films[1].getId()); + assertEquals(film3.getId(), films[2].getId()); + } + + @Test + void shouldReturnPopularWithCountLimit() { + createFilm("Film1"); + createFilm("Film2"); + createFilm("Film3"); + + Collection popular = filmService.getPopular(2); + assertEquals(2, popular.size()); + } + + @Test + void shouldThrowWhenCountIsZeroOrNegative() { + assertThrows(ValidationException.class, () -> filmService.getPopular(0)); + assertThrows(ValidationException.class, () -> filmService.getPopular(-1)); + } +} \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java new file mode 100644 index 0000000..210d61e --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/service/UserServiceTest.java @@ -0,0 +1,127 @@ +package ru.yandex.practicum.filmorate.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.user.InMemoryUserStorage; + +import java.time.LocalDate; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +class UserServiceTest { + + private UserService userService; + + @BeforeEach + void setUp() { + userService = new UserService(new InMemoryUserStorage()); + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setName(login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userService.add(user); + } + + @Test + void shouldAddUser() { + User user = createUser("testLogin"); + assertNotNull(user.getId()); + } + + @Test + void shouldThrowWhenLoginContainsSpace() { + User user = new User(); + user.setEmail("test@mail.ru"); + user.setLogin("bad login"); + user.setName("Test"); + user.setBirthday(LocalDate.of(1990, 1, 1)); + + assertThrows(ValidationException.class, () -> userService.add(user)); + } + + @Test + void shouldReplaceEmptyNameWithLogin() { + User user = new User(); + user.setEmail("test@mail.ru"); + user.setLogin("testLogin"); + user.setName(""); + user.setBirthday(LocalDate.of(1990, 1, 1)); + + User saved = userService.add(user); + assertEquals("testLogin", saved.getName()); + } + + @Test + void shouldAddAndRemoveFriend() { + User user1 = createUser("user1"); + User user2 = createUser("user2"); + + userService.addFriend(user1.getId(), user2.getId()); + + // Оба должны видеть друг друга + assertTrue(userService.findById(user1.getId()).getFriends().contains(user2.getId())); + assertTrue(userService.findById(user2.getId()).getFriends().contains(user1.getId())); + + userService.removeFriend(user1.getId(), user2.getId()); + + assertFalse(userService.findById(user1.getId()).getFriends().contains(user2.getId())); + assertFalse(userService.findById(user2.getId()).getFriends().contains(user1.getId())); + } + + @Test + void shouldReturnCommonFriends() { + User user1 = createUser("user1"); + User user2 = createUser("user2"); + User common = createUser("common"); + + userService.addFriend(user1.getId(), common.getId()); + userService.addFriend(user2.getId(), common.getId()); + + Collection commonFriends = userService.getCommonFriends( + user1.getId(), user2.getId() + ); + + assertEquals(1, commonFriends.size()); + assertEquals(common.getId(), commonFriends.iterator().next().getId()); + } + + @Test + void shouldReturnEmptyCommonFriendsWhenNone() { + User user1 = createUser("user1"); + User user2 = createUser("user2"); + + Collection commonFriends = userService.getCommonFriends( + user1.getId(), user2.getId() + ); + + assertTrue(commonFriends.isEmpty()); + } + + @Test + void shouldThrowWhenAddFriendToYourself() { + User user = createUser("user1"); + assertThrows(ValidationException.class, + () -> userService.addFriend(user.getId(), user.getId())); + } + + @Test + void shouldThrowWhenRemoveFriendYourself() { + User user = createUser("user1"); + assertThrows(ValidationException.class, + () -> userService.removeFriend(user.getId(), user.getId())); + } + + @Test + void shouldThrowWhenGetCommonFriendsWithYourself() { + User user = createUser("user1"); + assertThrows(ValidationException.class, + () -> userService.getCommonFriends(user.getId(), user.getId())); + } +} \ No newline at end of file