diff --git a/README.md b/README.md index 50b4159..3a007d4 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,145 @@ # java-filmorate -Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, -оценками пользователей и топом популярных фильмов. +Учебный backend-проект на Java и Spring Boot: сервис для работы с фильмами, лайками, +отзывами и рекомендациями. Данные хранятся в реляционной базе H2, доступ к ним — через `JdbcTemplate`. -Проект развивается поэтапно. На текущем этапе данные хранятся в реляционной базе H2, -доступ к ним — через `JdbcTemplate`, у фильмов появились жанры и возрастной рейтинг MPA. +## Команда -## Автор -**Ксения Пылькина** *(Ksenia Pylkina)* +| Участник | GitHub | Зона ответственности | +|---|---|---| +| **Ксения Пылькина** *(тимлид)* | [ksupyl](https://github.com/ksupyl) | этапы 1–3; общие фильмы, удаление фильмов и пользователей | +| **Кирилл Казанцев** | [Kirill-Kazantcev](https://github.com/Kirill-Kazantcev) | популярные фильмы по жанру и году, рекомендации | +| **Роман Егоров** | [Roman32-gif](https://github.com/Roman32-gif) | режиссёры, поиск | +| **Дарья Приндина** | [DariaPrindina](https://github.com/DariaPrindina) | отзывы, лента событий | -## Ключевые возможности на данном этапе +## Реализация по этапам + +Этапы 1–3 — индивидуальные спринты, этап 4 — групповой проект, собранный на их основе. + +
+Этап 1 — Каркас Spring Boot приложения и базовый REST API + +На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot, +реализованы базовые модели данных и контроллеры. + +**Что сделано:** +- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle. +- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`). +- Реализованы REST-контроллеры `FilmController` и `UserController`. +- 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-тесты обновлены под новую архитектуру. + +
+ +
+Этап 3 — База данных H2, DAO, жанры и рейтинги + +На этом этапе данные переехали из памяти в базу данных, +у фильмов появились жанры и возрастной рейтинг MPA. -- CRUD-операции для `Film` и `User` (создание, обновление, получение по ID и списком). +**Что сделано:** +- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`. +- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres` + и таблицы-связки `film_genres`, `film_likes`, `friendship`. +- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`, + `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности. +- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`). +- Сервисы переключены на хранилища в базе через `@Qualifier`; + in-memory реализации и их unit-тесты после перехода на базу удалены. +- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`. +- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом + только по этим фильмам (`IN (...)`). +- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого. +- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков. +- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404. +- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти). + +
+ +
+Этап 4 — Групповой проект: восемь новых функций + +Команда из четырёх человек добавила восемь функций. Работали по GitHub Flow: задача на доске +GitHub Projects, своя ветка на каждую функцию, pull request в `develop`, два апрува от команды +и Squash and merge. Каждый pull request проверял CI с тестами Postman по своей функции. + +**Что сделано:** +- **Популярные фильмы по жанру и году** *(Кирилл Казанцев)*: фильтры добавляются в `WHERE` + динамически; жанр проверяется подзапросом, чтобы фильм с несколькими жанрами не задваивался. +- **Общие фильмы** *(Ксения Пылькина)*: тот же запрос, что у популярных, с условием + «фильм лайкнул пользователь» для каждого из двух id. +- **Удаление фильмов и пользователей** *(Ксения Пылькина)*: связанные записи — лайки, жанры, + дружбу, отзывы, ленту — удаляет база через `ON DELETE CASCADE`. +- **Режиссёры** *(Роман Егоров)*: таблицы `directors` и `film_director`, у фильма — список режиссёров, + фильмы режиссёра сортируются по году выпуска или по лайкам. +- **Отзывы** *(Дарья Приндина)*: таблицы `reviews` и `review_likes`; рейтинг полезности + считается в SQL через `SUM(CASE WHEN is_useful THEN 1 ELSE -1 END)`. +- **Лента событий** *(Дарья Приндина)*: таблица `feed_events`; событие пишут сервисы лайков, + друзей и отзывов в той же транзакции, что и само действие. +- **Рекомендации** *(Кирилл Казанцев)*: находим всех пользователей с максимальным пересечением + по лайкам и возвращаем их фильмы, которых нет у нашего. Запрос фильмов живёт в `FilmDbStorage`, + поэтому жанры и режиссёры в ответе подтягиваются тем же путём, что и у остальных списочных запросов. +- **Поиск** *(Роман Егоров)*: подстрока без учёта регистра в названии и/или имени режиссёра, + результаты по убыванию популярности. +- Перед сдачей: ошибки клиента (нет обязательного параметра, неверный тип, битый JSON, + нет id при обновлении) отдают 400 вместо 500, неизвестный адрес — 404; записи в несколько + таблиц выполняются в транзакции; обработка ошибок покрыта тестами на `MockMvc`. +- Все хранилища покрыты интеграционными тестами (`@JdbcTest`). + +
+ +## Ключевые возможности + +- CRUD-операции для фильмов, пользователей и режиссёров; при удалении фильма или пользователя + связанные записи удаляются каскадно. - Хранение данных в базе H2 (файловый режим — данные переживают перезапуск), доступ через `JdbcTemplate` и `RowMapper`. - Справочники жанров и рейтингов MPA в базе, эндпоинты `/genres` и `/mpa`. -- У фильма есть рейтинг и список жанров; жанры в ответе — без дублей, по порядку id. +- У фильма есть рейтинг, список жанров и список режиссёров; в ответе — без дублей, по порядку id. - Односторонняя дружба: добавление/удаление друзей, список друзей, общие друзья. -- Лайки фильмам: добавление/удаление, топ-N фильмов по количеству лайков. +- Лайки фильмам и топ-N популярных фильмов с фильтрами по жанру и году выпуска. +- Общие фильмы двух пользователей — те, что лайкнули оба. +- Фильмы режиссёра с сортировкой по году выпуска или количеству лайков. +- Поиск фильмов по подстроке в названии и/или имени режиссёра. +- Отзывы к фильмам: положительные и негативные, с рейтингом полезности по лайкам и дизлайкам. +- Рекомендации фильмов: пользователю предлагаются фильмы, которые лайкнули + пользователи с максимальным пересечением по лайкам (при равенстве — все + такие пользователи). +- Лента событий пользователя: лайки, друзья и отзывы. - Слоистая архитектура: контроллер → сервис → хранилище, выбор реализации хранилища через `@Qualifier`. - Внедрение зависимостей через конструктор (`@Autowired`). - Строгая валидация входящих данных через `spring-boot-starter-validation`. - Кастомная бизнес-валидация (дата релиза, пробелы в логине, автозамена имени на логин, - существование рейтинга и жанров). -- Централизованная обработка ошибок (`@RestControllerAdvice`): 400, 404, 500. + существование рейтинга, жанров и режиссёров). +- Записи сразу в несколько таблиц выполняются в одной транзакции (`@Transactional`). +- Централизованная обработка ошибок (`@RestControllerAdvice`): ошибки клиента — 400, 404, 405 и 415, + непредвиденные — 500 с общим сообщением, подробности только в логе. - Логирование HTTP-запросов и ответов через Logbook. -- Unit-тесты бизнес-логики и интеграционные тесты хранилищ (`@JdbcTest`). +- Интеграционные тесты хранилищ (`@JdbcTest`), тесты ленты событий и обработки ошибок + (`@SpringBootTest`, `MockMvc`). ## Технологии @@ -37,7 +151,7 @@ - **H2 Database** (встроенная реляционная база данных) - **Logbook 3.7.2** (логирование HTTP-запросов) - **Lombok** (генерация boilerplate-кода и логгеров) -- **JUnit 5**, **Spring Boot Test**, **Mockito** (unit- и интеграционные тесты) +- **JUnit 5**, **Spring Boot Test** (`@JdbcTest`, `@SpringBootTest`, `MockMvc`) - **Maven** (сборка проекта) - **Checkstyle** (контроль стиля кода) @@ -48,7 +162,7 @@ 3. Дождаться загрузки зависимостей Maven. 4. Запустить метод `main` в классе `FilmorateApplication`. -После запуска сервер будет доступен на порту **8080**. +После запуска сервер будет доступен на порту **8080**. Тесты запускаются командой `mvn test`. База данных создаётся автоматически при старте: таблицы — из `schema.sql`, справочники жанров и рейтингов — из `data.sql`. Данные хранятся в папке `db` @@ -67,19 +181,43 @@ - `GET /films/{id}` — получить фильм по ID. - `POST /films` — добавить новый фильм. - `PUT /films` — обновить существующий фильм. +- `DELETE /films/{id}` — удалить фильм; связанные с ним записи удаляются каскадно. - `PUT /films/{id}/like/{userId}` — поставить лайк фильму. - `DELETE /films/{id}/like/{userId}` — убрать лайк. -- `GET /films/popular?count={count}` — топ-N фильмов по лайкам (по умолчанию 10). +- `GET /films/popular?count={count}&genreId={genreId}&year={year}` — топ-N фильмов по лайкам + (по умолчанию 10); жанр и год — необязательные фильтры. +- `GET /films/common?userId={userId}&friendId={friendId}` — общие фильмы двух пользователей, + по убыванию популярности. +- `GET /films/director/{directorId}?sortBy={year|likes}` — фильмы режиссёра по году выпуска + или по количеству лайков (по умолчанию `likes`). +- `GET /films/search?query={query}&by={title,director}` — поиск по подстроке без учёта регистра + в названии и/или имени режиссёра, по убыванию популярности. ### Пользователи (`/users`) - `GET /users` — получить список всех пользователей. - `GET /users/{id}` — получить пользователя по ID. - `POST /users` — создать нового пользователя. - `PUT /users` — обновить данные пользователя. +- `DELETE /users/{id}` — удалить пользователя; связанные с ним записи удаляются каскадно. - `PUT /users/{id}/friends/{friendId}` — добавить в друзья (дружба односторонняя). - `DELETE /users/{id}/friends/{friendId}` — удалить из друзей. - `GET /users/{id}/friends` — список друзей пользователя. - `GET /users/{id}/friends/common/{otherId}` — общие друзья с другим пользователем. +- `GET /users/{id}/recommendations` — рекомендации фильмов на основе похожих пользователей. +- `GET /users/{id}/feed` — лента событий пользователя по порядку: лайки (`LIKE`), друзья (`FRIEND`) + и отзывы (`REVIEW`) с операциями `ADD`, `REMOVE`, `UPDATE`. + +### Отзывы (`/reviews`) +- `POST /reviews` — добавить отзыв. +- `PUT /reviews` — изменить текст и тип отзыва. +- `DELETE /reviews/{id}` — удалить отзыв. +- `GET /reviews/{id}` — получить отзыв по ID. +- `GET /reviews?filmId={filmId}&count={count}` — отзывы к фильму (без `filmId` — ко всем), + по убыванию полезности; по умолчанию 10. +- `PUT /reviews/{id}/like/{userId}` — отметить отзыв полезным. +- `PUT /reviews/{id}/dislike/{userId}` — отметить отзыв бесполезным. +- `DELETE /reviews/{id}/like/{userId}` — убрать лайк. +- `DELETE /reviews/{id}/dislike/{userId}` — убрать дизлайк. ### Жанры (`/genres`) - `GET /genres` — получить список всех жанров. @@ -89,6 +227,13 @@ - `GET /mpa` — получить список всех рейтингов. - `GET /mpa/{id}` — получить рейтинг по ID. +### Режиссёры (`/directors`) +- `GET /directors` — получить список всех режиссёров. +- `GET /directors/{id}` — получить режиссёра по ID. +- `POST /directors` — создать нового режиссёра. +- `PUT /directors` — обновить данные режиссёра. +- `DELETE /directors/{id}` — удалить режиссёра. + ## Схема базы данных ```mermaid @@ -100,6 +245,13 @@ erDiagram users ||--o{ film_likes : "user_id" users ||--o{ friendship : "user_id" users ||--o{ friendship : "friend_id" + films ||--o{film_director : "film_id" + directors ||--o{film_director : "director_id" + users ||--o{ reviews : "user_id" + films ||--o{ reviews : "film_id" + reviews ||--o{ review_likes : "review_id" + users ||--o{ review_likes : "user_id" + users ||--o{ feed_events : "user_id" films { BIGINT id PK @@ -136,6 +288,34 @@ erDiagram BIGINT user_id PK, FK BIGINT friend_id PK, FK } + directors { + BIGINT id PK + VARCHAR name + } + film_director { + BIGINT film_id PK, FK + BIGINT director_id PK, FK + } + reviews { + BIGINT id PK + VARCHAR content + BOOLEAN is_positive + BIGINT user_id FK + BIGINT film_id FK + } + review_likes { + BIGINT review_id PK, FK + BIGINT user_id PK, FK + BOOLEAN is_useful "TRUE — лайк, FALSE — дизлайк" + } + feed_events { + BIGINT event_id PK + BIGINT user_id FK + VARCHAR event_type "LIKE, REVIEW, FRIEND" + VARCHAR operation "ADD, REMOVE, UPDATE" + BIGINT entity_id "id фильма, друга или отзыва" + BIGINT created_at "время события, мс" + } ``` | Таблица | Что хранит | @@ -147,6 +327,11 @@ erDiagram | `film_genres` | связь «фильм — жанр», многие ко многим | | `film_likes` | лайки: какой пользователь лайкнул какой фильм | | `friendship` | дружба: одна строка — `user_id` добавил в друзья `friend_id`; подтверждённая (взаимная) дружба — две встречные строки | +| `directors` | режиссёры | +| `film_director` | связь "фильм — режиссёр", многие ко многим | +| `reviews` | отзывы пользователей к фильмам; рейтинг полезности не хранится, а считается по `review_likes` | +| `review_likes` | оценки отзывов: одна строка — один пользователь, лайк или дизлайк | +| `feed_events` | лента: что пользователь лайкнул, кого добавил в друзья, какие отзывы написал | У таблиц-связок составной первичный ключ, поэтому одна и та же пара не запишется дважды. При удалении фильма или пользователя связанные с ним записи удаляются каскадно. @@ -176,6 +361,21 @@ WHERE fg.film_id = 1 ORDER BY g.id; ``` +Поиск «нол» по имени режиссёра и названию, по убыванию популярности. +`COUNT(DISTINCT ...)` — потому что у фильма с двумя режиссёрами каждый лайк иначе посчитался бы дважды: + +```sql +SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name +FROM films f +LEFT JOIN mpa m ON f.mpa_id = m.id +LEFT JOIN film_director fd ON f.id = fd.film_id +LEFT JOIN directors d ON fd.director_id = d.id +LEFT JOIN film_likes fl ON f.id = fl.film_id +WHERE LOWER(d.name) LIKE '%нол%' OR LOWER(f.name) LIKE '%нол%' +GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name +ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id; +``` + Друзья пользователя с id = 1 — те, кого он добавил: ```sql @@ -196,69 +396,34 @@ JOIN friendship f2 ON u.id = f2.friend_id AND f2.user_id = 2 ORDER BY u.id; ``` - - -## Реализация по этапам - -
-Этап 1 — Каркас Spring Boot приложения и базовый REST API - -На этом этапе был заложен фундамент будущего сервиса. Проект переведён на Spring Boot, -реализованы базовые модели данных и контроллеры. - -**Что сделано:** -- Настроен `pom.xml`, подключены стартеры Spring Boot, Lombok и плагин Checkstyle. -- Созданы модели `Film` и `User` с аннотациями Lombok (`@Data`). -- Реализованы REST-контроллеры `FilmController` и `UserController`. -- In-memory хранение данных с ручной генерацией ID. -- Валидация через аннотации `jakarta.validation.constraints`. -- Кастомная бизнес-валидация (дата релиза, пробелы в логине). -- Логирование запросов и ошибок. -- Unit-тесты для проверки логики валидации. - -
- -
-Этап 2 — Слоистая архитектура, социальные функции, полный REST - -На этом этапе логика вынесена из контроллеров, добавлены друзья и лайки, -API доведён до соответствия REST. +Рекомендации для пользователя с id = 1: фильмы всех пользователей +с максимальным пересечением по лайкам, которых нет у нашего: -**Что сделано:** -- Созданы интерфейсы `FilmStorage` и `UserStorage` и их реализации - `InMemoryFilmStorage` и `InMemoryUserStorage` (`@Component`). -- Созданы `FilmService` (`@Service`) и `UserService` (`@Service`) - с бизнес-логикой лайков и друзей. -- Контроллеры переработаны: зависят от сервисов, внедрение через конструктор. -- Сервисы зависят от интерфейсов хранилищ — легко заменить реализацию в будущем. -- Добавлены все новые эндпоинты (друзья, лайки, топ фильмов, получение по ID). -- Централизованная обработка ошибок через `@RestControllerAdvice`: 400, 404, 500. -- Подключён Logbook для логирования HTTP-запросов и ответов. -- Unit-тесты обновлены под новую архитектуру. +```sql +-- Шаг 1: максимальное пересечение лайков с любым другим пользователем +SELECT MAX(common) AS max_common FROM ( + SELECT COUNT(*) AS common FROM film_likes fl1 + JOIN film_likes fl2 ON fl1.film_id = fl2.film_id + WHERE fl1.user_id = 1 AND fl2.user_id <> 1 + GROUP BY fl2.user_id +) t; + +-- Шаг 2: все пользователи, у которых пересечение равно максимуму +SELECT fl2.user_id AS other_id FROM film_likes fl1 +JOIN film_likes fl2 ON fl1.film_id = fl2.film_id +WHERE fl1.user_id = 1 AND fl2.user_id <> 1 +GROUP BY fl2.user_id +HAVING COUNT(*) = ; + +-- Шаг 3: их фильмы, которых нет у пользователя 1 +SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name +FROM films f +JOIN film_likes fl ON f.id = fl.film_id +LEFT JOIN mpa m ON f.mpa_id = m.id +WHERE fl.user_id IN () + AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = 1) +GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name +ORDER BY f.id; +```
- -
-Этап 3 — База данных H2, DAO, жанры и рейтинги - -На этом этапе данные переехали из памяти в базу данных, -у фильмов появились жанры и возрастной рейтинг MPA. - -**Что сделано:** -- Подключены H2 и `spring-boot-starter-jdbc`; схема базы — `schema.sql`, справочники — `data.sql`. -- Спроектированы 7 таблиц: `films`, `users`, `mpa`, `genres` - и таблицы-связки `film_genres`, `film_likes`, `friendship`. -- Созданы хранилища на `JdbcTemplate`: `FilmDbStorage`, `UserDbStorage`, - `GenreDbStorage`, `MpaDbStorage` и мапперы `RowMapper` для каждой сущности. -- Хранилища жанров и рейтингов, как и остальные, скрыты за интерфейсами (`GenreStorage`, `MpaStorage`). -- Сервисы переключены на хранилища в базе через `@Qualifier`; - in-memory реализации оставлены для unit-тестов. -- Добавлены модели `Genre` и `Mpa`, сервисы и контроллеры `/genres` и `/mpa`. -- Жанры фильма сохраняются пакетно (`batchUpdate`), а для списка фильмов загружаются одним запросом - только по этим фильмам (`IN (...)`). -- Дружба стала односторонней: одна запись в `friendship` — один пользователь добавил другого. -- Популярные фильмы считаются в SQL: `LEFT JOIN` с лайками, `GROUP BY`, сортировка по числу лайков. -- При создании и обновлении фильма проверяются рейтинг и жанры: неизвестный id — 404. -- Интеграционные тесты всех хранилищ (`@JdbcTest`, база в памяти); unit-тесты сервисов обновлены. - -
\ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java new file mode 100644 index 0000000..cf4b906 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/DirectorController.java @@ -0,0 +1,53 @@ +package ru.yandex.practicum.filmorate.controller; + +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.model.Director; +import ru.yandex.practicum.filmorate.service.DirectorService; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/directors") +public class DirectorController { + + private final DirectorService directorService; + + @Autowired + public DirectorController(DirectorService directorService) { + this.directorService = directorService; + } + + @GetMapping + public List getDirectors() { + log.info("GET /directors"); + return directorService.getAllDirectors(); + } + + @GetMapping("/{id}") + public Director getDirectorById(@PathVariable long id) { + log.info("GET /directors/{}", id); + return directorService.getDirectorById(id); + } + + @PostMapping + public Director createDirector(@Valid @RequestBody Director director) { + log.info("POST /directors, body:{}", director); + return directorService.addDirector(director); + } + + @PutMapping + public Director updateDirector(@Valid @RequestBody Director director) { + log.info("PUT /directors, body:{}", director); + return directorService.updateDirector(director); + } + + @DeleteMapping("/{id}") + public void deleteDirector(@PathVariable long id) { + log.info("DELETE /directors/{}", id); + directorService.deleteDirector(id); + } +} 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 21c3dda..3eebc6b 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/FilmController.java @@ -8,6 +8,7 @@ import ru.yandex.practicum.filmorate.service.FilmService; import java.util.Collection; +import java.util.List; @RestController @RequestMapping("/films") @@ -45,6 +46,12 @@ public Film update(@Valid @RequestBody Film film) { return filmService.update(film); } + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /films/{}", id); + filmService.delete(id); + } + @PutMapping("/{id}/like/{userId}") public Film addLike(@PathVariable long id, @PathVariable long userId) { log.info("PUT /films/{}/like/{}", id, userId); @@ -59,8 +66,30 @@ public Film removeLike(@PathVariable long id, @PathVariable long userId) { @GetMapping("/popular") public Collection getPopular( - @RequestParam(defaultValue = "10") int count) { - log.info("GET /films/popular?count={}", count); - return filmService.getPopular(count); + @RequestParam(defaultValue = "10") int count, + @RequestParam(required = false) Integer genreId, + @RequestParam(required = false) Integer year) { + log.info("GET /films/popular?count={}&genreId={}&year={}", count, genreId, year); + return filmService.getPopular(count, genreId, year); + } + + @GetMapping("/common") + public Collection getCommon(@RequestParam long userId, @RequestParam long friendId) { + log.info("GET /films/common?userId={}&friendId={}", userId, friendId); + return filmService.getCommon(userId, friendId); + } + + @GetMapping("/director/{directorId}") + public Collection getFilmsByDirector(@PathVariable long directorId, + @RequestParam(defaultValue = "likes") String sortBy) { + log.info("GET /films/director/{}?sortBy={}", directorId, sortBy); + return filmService.findByDirectorSorted(directorId, sortBy); + } + + @GetMapping("/search") + public Collection searchFilms(@RequestParam String query, + @RequestParam List by) { + log.info("GET /films/search?query={}&by={}", query, by); + return filmService.searchFilms(query, by); } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java new file mode 100644 index 0000000..e84423a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/ReviewController.java @@ -0,0 +1,79 @@ +package ru.yandex.practicum.filmorate.controller; + +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.model.Review; +import ru.yandex.practicum.filmorate.service.ReviewService; + +import java.util.Collection; + +@RestController +@RequestMapping("/reviews") +@Slf4j +public class ReviewController { + + private final ReviewService reviewService; + + @Autowired + public ReviewController(ReviewService reviewService) { + this.reviewService = reviewService; + } + + @PostMapping + public Review create(@Valid @RequestBody Review review) { + log.info("POST /reviews : userId={}, filmId={}", review.getUserId(), review.getFilmId()); + return reviewService.add(review); + } + + @PutMapping + public Review update(@Valid @RequestBody Review review) { + log.info("PUT /reviews : id={}", review.getReviewId()); + return reviewService.update(review); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /reviews/{}", id); + reviewService.delete(id); + } + + @GetMapping("/{id}") + public Review findById(@PathVariable long id) { + log.info("GET /reviews/{}", id); + return reviewService.findById(id); + } + + @GetMapping + public Collection findMostUseful( + @RequestParam(required = false) Long filmId, + @RequestParam(defaultValue = "10") int count) { + log.info("GET /reviews?filmId={}&count={}", filmId, count); + return reviewService.findMostUseful(filmId, count); + } + + @PutMapping("/{id}/like/{userId}") + public void addLike(@PathVariable long id, @PathVariable long userId) { + log.info("PUT /reviews/{}/like/{}", id, userId); + reviewService.addLike(id, userId); + } + + @PutMapping("/{id}/dislike/{userId}") + public void addDislike(@PathVariable long id, @PathVariable long userId) { + log.info("PUT /reviews/{}/dislike/{}", id, userId); + reviewService.addDislike(id, userId); + } + + @DeleteMapping("/{id}/like/{userId}") + public void removeLike(@PathVariable long id, @PathVariable long userId) { + log.info("DELETE /reviews/{}/like/{}", id, userId); + reviewService.removeLike(id, userId); + } + + @DeleteMapping("/{id}/dislike/{userId}") + public void removeDislike(@PathVariable long id, @PathVariable long userId) { + log.info("DELETE /reviews/{}/dislike/{}", id, userId); + reviewService.removeDislike(id, userId); + } +} 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 ba0d0ec..7a4718e 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java +++ b/src/main/java/ru/yandex/practicum/filmorate/controller/UserController.java @@ -4,10 +4,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.service.UserService; import java.util.Collection; +import java.util.List; @RestController @RequestMapping("/users") @@ -45,6 +48,12 @@ public User update(@Valid @RequestBody User user) { return userService.update(user); } + @DeleteMapping("/{id}") + public void delete(@PathVariable long id) { + log.info("DELETE /users/{}", id); + userService.delete(id); + } + @PutMapping("/{id}/friends/{friendId}") public User addFriend(@PathVariable long id, @PathVariable long friendId) { log.info("PUT /users/{}/friends/{}", id, friendId); @@ -70,4 +79,16 @@ public Collection getCommonFriends( log.info("GET /users/{}/friends/common/{}", id, otherId); return userService.getCommonFriends(id, otherId); } + + @GetMapping("/{id}/recommendations") + public Collection getRecommendations(@PathVariable long id) { + log.info("GET /users/{}/recommendations", id); + return userService.getRecommendations(id); + } + + @GetMapping("/{id}/feed") + public List getFeed(@PathVariable long id) { + log.info("GET /users/{}/feed", id); + return userService.getFeed(id); + } } \ 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 index a7128b6..8ef6b47 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java +++ b/src/main/java/ru/yandex/practicum/filmorate/exception/ErrorHandler.java @@ -2,12 +2,19 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Map; +import java.util.stream.Collectors; @RestControllerAdvice @Slf4j @@ -27,18 +34,70 @@ public Map handleNotFoundException(NotFoundException e) { return Map.of("error", e.getMessage()); } + // Нарушены аннотации валидации (@NotBlank, @Positive и т.п.): в ответе — поле и что с ним не так @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) - public Map handleMethodArgumentNotValidException( - MethodArgumentNotValidException e) { + public Map handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining("; ")); + log.warn("400: {}", message); + return Map.of("error", message); + } + + // Нет обязательного параметра, например GET /films/search без by + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleMissingParameter(MissingServletRequestParameterException e) { log.warn("400: {}", e.getMessage()); - return Map.of("error", e.getMessage()); + return Map.of("error", "Не указан обязательный параметр '" + e.getParameterName() + "'"); + } + + // Параметр не того типа, например GET /films/abc вместо числового id + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleTypeMismatch(MethodArgumentTypeMismatchException e) { + log.warn("400: {}", e.getMessage()); + return Map.of("error", "Некорректное значение параметра '" + e.getName() + "': " + e.getValue()); } + // Тело запроса не читается как JSON нужной структуры + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleNotReadableBody(HttpMessageNotReadableException e) { + log.warn("400: {}", e.getMessage()); + return Map.of("error", "Некорректное тело запроса"); + } + + // Запрос на адрес, которого нет в API + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_FOUND) + public Map handleNoResource(NoResourceFoundException e) { + log.warn("404: {}", e.getMessage()); + return Map.of("error", "Адрес /" + e.getResourcePath() + " не найден"); + } + + // Адрес есть, но с другим HTTP-методом, например POST /films/1 + @ExceptionHandler + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public Map handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + log.warn("405: {}", e.getMessage()); + return Map.of("error", "Метод " + e.getMethod() + " для этого адреса не поддерживается"); + } + + // Тело пришло не в JSON, например с Content-Type: text/plain + @ExceptionHandler + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + public Map handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) { + log.warn("415: {}", e.getMessage()); + return Map.of("error", "Тело запроса должно быть в формате JSON"); + } + + // Непредвиденная ошибка: подробности только в лог, клиенту — общее сообщение без SQL и стектрейсов @ExceptionHandler @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Map handleException(Exception e) { log.error("500: {}", e.getMessage(), e); - return Map.of("error", e.getMessage()); + return Map.of("error", "Внутренняя ошибка сервера"); } -} \ No newline at end of file +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Director.java b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java new file mode 100644 index 0000000..3fe4162 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Director.java @@ -0,0 +1,15 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +// Равенство по id, как у Genre и Mpa: дубль режиссёра в запросе схлопнется в один +@Data +@EqualsAndHashCode(of = "id") +public class Director { + private Long id; + + @NotBlank(message = "Имя режиссёра не может быть пустым") + private String name; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Event.java b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java new file mode 100644 index 0000000..86331a3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Event.java @@ -0,0 +1,13 @@ +package ru.yandex.practicum.filmorate.model; + +import lombok.Data; + +@Data +public class Event { + private Long eventId; + private Long timestamp; + private Long userId; + private EventType eventType; + private Operation operation; + private Long entityId; +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java new file mode 100644 index 0000000..5de71e3 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/EventType.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.model; + +public enum EventType { + LIKE, + REVIEW, + FRIEND +} 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 08a9aca..2b3a240 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/Film.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Film.java @@ -1,6 +1,5 @@ package ru.yandex.practicum.filmorate.model; -import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -8,7 +7,6 @@ import lombok.Data; import java.time.LocalDate; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; @@ -34,15 +32,5 @@ public class Film { private Mpa mpa; private Set genres = new LinkedHashSet<>(); - - @JsonIgnore - private Set likes = new HashSet<>(); - - public void addLike(Long userId) { - likes.add(userId); - } - - public boolean removeLike(Long userId) { - return likes.remove(userId); - } + private Set directors = new LinkedHashSet<>(); } diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java new file mode 100644 index 0000000..797ccbd --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Operation.java @@ -0,0 +1,7 @@ +package ru.yandex.practicum.filmorate.model; + +public enum Operation { + ADD, + REMOVE, + UPDATE +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/model/Review.java b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java new file mode 100644 index 0000000..e8a4c41 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/model/Review.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum.filmorate.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class Review { + private Long reviewId; + + @NotBlank(message = "Текст отзыва не может быть пустым") + private String content; + + @NotNull(message = "Тип отзыва должен быть указан") + private Boolean isPositive; + + @NotNull(message = "Автор отзыва должен быть указан") + private Long userId; + + @NotNull(message = "Фильм должен быть указан") + private Long filmId; + + private int useful; +} 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 86cda7c..33003b4 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/model/User.java +++ b/src/main/java/ru/yandex/practicum/filmorate/model/User.java @@ -1,6 +1,5 @@ package ru.yandex.practicum.filmorate.model; -import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -8,8 +7,6 @@ import lombok.Data; import java.time.LocalDate; -import java.util.HashSet; -import java.util.Set; @Data public class User { @@ -27,15 +24,4 @@ public class User { @NotNull(message = "Дата рождения не может быть пустой") @PastOrPresent(message = "Дата рождения не может быть в будущем") private LocalDate birthday; - - @JsonIgnore - 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/DirectorService.java b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java new file mode 100644 index 0000000..4c7f827 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/DirectorService.java @@ -0,0 +1,46 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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.Director; +import ru.yandex.practicum.filmorate.storage.director.DirectorStorage; + +import java.util.List; + +@Service +public class DirectorService { + + private final DirectorStorage directorStorage; + + @Autowired + public DirectorService(@Qualifier("directorDbStorage") DirectorStorage directorStorage) { + this.directorStorage = directorStorage; + } + + public Director addDirector(Director director) { + return directorStorage.addDirector(director); + } + + public Director updateDirector(Director director) { + if (director.getId() == null) { + throw new ValidationException("Не указан id режиссёра"); + } + return directorStorage.updateDirector(director); + } + + public void deleteDirector(long id) { + directorStorage.removeDirector(id); + } + + public Director getDirectorById(long id) { + return directorStorage.findDirectorById(id) + .orElseThrow(() -> new NotFoundException("Режиссёр с id=" + id + " не найден")); + } + + public List getAllDirectors() { + return directorStorage.findAllDirectors(); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java new file mode 100644 index 0000000..392d84a --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FeedService.java @@ -0,0 +1,36 @@ +package ru.yandex.practicum.filmorate.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.storage.feed.FeedStorage; + +import java.time.Instant; +import java.util.List; + +@Service +public class FeedService { + + private final FeedStorage feedStorage; + + @Autowired + public FeedService(FeedStorage feedStorage) { + this.feedStorage = feedStorage; + } + + public void addEvent(long userId, EventType eventType, Operation operation, long entityId) { + Event event = new Event(); + event.setTimestamp(Instant.now().toEpochMilli()); + event.setUserId(userId); + event.setEventType(eventType); + event.setOperation(operation); + event.setEntityId(entityId); + feedStorage.add(event); + } + + public List findByUserId(long userId) { + return feedStorage.findByUserId(userId); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java index 160de6e..6fa0537 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/FilmService.java @@ -4,16 +4,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.model.EventType; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.user.UserStorage; import java.time.LocalDate; import java.util.Collection; import java.util.HashSet; +import java.util.List; +import java.util.Locale; import java.util.Set; @Service @@ -30,16 +36,22 @@ public class FilmService { // Нужны для проверки рейтинга и жанра из запроса private final MpaService mpaService; private final GenreService genreService; + private final DirectorService directorService; + private final FeedService feedService; @Autowired public FilmService(@Qualifier("filmDbStorage") FilmStorage filmStorage, @Qualifier("userDbStorage") UserStorage userStorage, MpaService mpaService, - GenreService genreService) { + GenreService genreService, + DirectorService directorService, + FeedService feedService) { this.filmStorage = filmStorage; this.userStorage = userStorage; this.mpaService = mpaService; this.genreService = genreService; + this.directorService = directorService; + this.feedService = feedService; } // Получение фильма по id с проверкой существования @@ -54,15 +66,19 @@ private void getUserOrThrow(long id) { .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); } + // Лайк и событие в ленте пишутся вместе: либо оба, либо ни одного + @Transactional public Film addLike(long filmId, long userId) { Film film = getFilmOrThrow(filmId); getUserOrThrow(userId); // проверка существования пользователя — иначе 404 filmStorage.addLike(filmId, userId); + feedService.addEvent(userId, EventType.LIKE, Operation.ADD, filmId); log.debug("Пользователь id={} поставил лайк фильму id={}", userId, filmId); return film; } + @Transactional public Film removeLike(long filmId, long userId) { Film film = getFilmOrThrow(filmId); getUserOrThrow(userId); // проверка существования пользователя — иначе 404 @@ -73,18 +89,36 @@ public Film removeLike(long filmId, long userId) { ); } + feedService.addEvent(userId, EventType.LIKE, Operation.REMOVE, filmId); log.debug("Пользователь id={} убрал лайк с фильма id={}", userId, filmId); return film; } - public Collection getPopular(int count) { + public Collection getPopular(int count, Integer genreId, Integer year) { // Валидация: count должен быть положительным числом if (count <= 0) { throw new ValidationException( "Количество фильмов должно быть положительным числом, получено: " + count ); } - return filmStorage.findPopular(count); + // Жанр, если указан, должен существовать — иначе 404 + if (genreId != null) { + genreService.findById(genreId); + } + // Валидация: год, если указан, должен быть положительным + if (year != null && year <= 0) { + throw new ValidationException( + "Год должен быть положительным числом, получено: " + year + ); + } + return filmStorage.findPopular(count, genreId, year); + } + + // Общие фильмы двух пользователей — те, что лайкнули оба, по убыванию популярности + public Collection getCommon(long userId, long friendId) { + getUserOrThrow(userId); // оба пользователя должны существовать — иначе 404 + getUserOrThrow(friendId); + return filmStorage.findCommon(userId, friendId); } // Валидация даты релиза — не может быть раньше дня рождения кино @@ -100,6 +134,9 @@ private void validateReleaseDate(Film film) { // Рейтинг и жанры из запроса должны существовать в справочниках — иначе 404 private void validateMpaAndGenres(Film film) { if (film.getMpa() != null) { + if (film.getMpa().getId() == null) { + throw new ValidationException("Не указан id рейтинга MPA"); + } mpaService.findById(film.getMpa().getId()); // бросит NotFoundException } if (film.getGenres() == null || film.getGenres().isEmpty()) { @@ -111,25 +148,58 @@ private void validateMpaAndGenres(Film film) { knownIds.add(genre.getId()); } for (Genre genre : film.getGenres()) { + if (genre.getId() == null) { + throw new ValidationException("Не указан id жанра"); + } if (!knownIds.contains(genre.getId())) { throw new NotFoundException("Жанр с id=" + genre.getId() + " не найден"); } } } + // Режиссёры из запроса должны существовать — иначе 404 + private void validateDirectors(Film film) { + if (film.getDirectors() == null || film.getDirectors().isEmpty()) { + return; + } + Set knownIds = new HashSet<>(); + for (Director director : directorService.getAllDirectors()) { + knownIds.add(director.getId()); + } + for (Director director : film.getDirectors()) { + if (director.getId() == null) { + throw new ValidationException("Не указан id режиссёра"); + } + if (!knownIds.contains(director.getId())) { + throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден"); + } + } + } + public Film add(Film film) { validateReleaseDate(film); validateMpaAndGenres(film); + validateDirectors(film); return filmStorage.add(film); } public Film update(Film film) { + if (film.getId() == null) { + throw new ValidationException("Не указан id фильма"); + } getFilmOrThrow(film.getId()); // проверка существования — иначе 404 validateReleaseDate(film); validateMpaAndGenres(film); + validateDirectors(film); return filmStorage.update(film); } + // Лайки, жанры и прочие связи фильма база удаляет сама — каскадом из schema.sql + public void delete(long id) { + filmStorage.delete(id); // нет фильма — хранилище бросит NotFoundException, это 404 + log.debug("Фильм id={} удалён", id); + } + public Collection findAll() { return filmStorage.findAll(); } @@ -137,4 +207,35 @@ public Collection findAll() { public Film findById(long id) { return getFilmOrThrow(id); } + + // Сортировка без учёта регистра: year или likes, остальное — 400 + public Collection findByDirectorSorted(long directorId, String sortBy) { + String sort = sortBy.toLowerCase(Locale.ROOT); + if (!sort.equals("year") && !sort.equals("likes")) { + throw new ValidationException("Сортировка возможна только по year или likes, получено: " + sortBy); + } + directorService.getDirectorById(directorId); // проверка существования режиссёра — иначе 404 + return filmStorage.findFilmByDirector(directorId, sort); + } + + private void validateSearchParameters(String query, List by) { + if (query == null || query.isBlank()) { + throw new ValidationException("Параметр 'query' не может быть пустым"); + } + if (by == null || by.isEmpty()) { + throw new ValidationException("Параметр 'by' не может быть пустым"); + } + for (String field : by) { + if (field == null || !field.equalsIgnoreCase("title") && !field.equalsIgnoreCase("director")) { + throw new ValidationException("Недопустимое значение в параметре 'by': " + field); + } + } + } + + public Collection searchFilms(String query, List by) { + validateSearchParameters(query, by); + // Проверка выше не учитывает регистр, а хранилище ждёт title и director строчными + List fields = by.stream().map(field -> field.toLowerCase(Locale.ROOT)).toList(); + return filmStorage.searchFilms(query, fields); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java new file mode 100644 index 0000000..2df51b0 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/service/ReviewService.java @@ -0,0 +1,132 @@ +package ru.yandex.practicum.filmorate.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.storage.film.FilmStorage; +import ru.yandex.practicum.filmorate.storage.review.ReviewStorage; +import ru.yandex.practicum.filmorate.storage.user.UserStorage; + +import java.util.Collection; + +@Service +@Slf4j +public class ReviewService { + + private final ReviewStorage reviewStorage; + private final FilmStorage filmStorage; + private final UserStorage userStorage; + private final FeedService feedService; + + @Autowired + public ReviewService(ReviewStorage reviewStorage, + @Qualifier("filmDbStorage") FilmStorage filmStorage, + @Qualifier("userDbStorage") UserStorage userStorage, + FeedService feedService) { + this.reviewStorage = reviewStorage; + this.filmStorage = filmStorage; + this.userStorage = userStorage; + this.feedService = feedService; + } + + private Review getReviewOrThrow(long id) { + return reviewStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Отзыв с id=" + id + " не найден")); + } + + private void checkUserExists(long id) { + userStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); + } + + private void checkFilmExists(long id) { + filmStorage.findById(id) + .orElseThrow(() -> new NotFoundException("Фильм с id=" + id + " не найден")); + } + + // Отзыв и событие в ленте пишутся вместе: либо оба, либо ни одного + @Transactional + public Review add(Review review) { + checkUserExists(review.getUserId()); + checkFilmExists(review.getFilmId()); + Review created = reviewStorage.add(review); + feedService.addEvent(created.getUserId(), EventType.REVIEW, Operation.ADD, created.getReviewId()); + log.debug("Пользователь id={} оставил отзыв id={} к фильму id={}", + created.getUserId(), created.getReviewId(), created.getFilmId()); + return created; + } + + // Автор, фильм и рейтинг полезности не меняются — только текст и тип отзыва + @Transactional + public Review update(Review review) { + if (review.getReviewId() == null) { + throw new ValidationException("Не указан id отзыва"); + } + getReviewOrThrow(review.getReviewId()); + Review updated = reviewStorage.update(review); + feedService.addEvent(updated.getUserId(), EventType.REVIEW, Operation.UPDATE, updated.getReviewId()); + return updated; + } + + @Transactional + public void delete(long id) { + Review review = getReviewOrThrow(id); + reviewStorage.delete(id); + feedService.addEvent(review.getUserId(), EventType.REVIEW, Operation.REMOVE, id); + log.debug("Отзыв id={} удалён", id); + } + + public Review findById(long id) { + return getReviewOrThrow(id); + } + + public Collection findMostUseful(Long filmId, int count) { + if (count <= 0) { + throw new ValidationException( + "Количество отзывов должно быть положительным числом, получено: " + count + ); + } + if (filmId != null) { + checkFilmExists(filmId); + } + return reviewStorage.findMostUseful(filmId, count); + } + + public void addLike(long reviewId, long userId) { + addRating(reviewId, userId, true); + } + + public void addDislike(long reviewId, long userId) { + addRating(reviewId, userId, false); + } + + public void removeLike(long reviewId, long userId) { + removeRating(reviewId, userId, true); + } + + public void removeDislike(long reviewId, long userId) { + removeRating(reviewId, userId, false); + } + + // Повторная оценка заменяет предыдущую: лайк после дизлайка меняет рейтинг на +2 + private void addRating(long reviewId, long userId, boolean isUseful) { + getReviewOrThrow(reviewId); + checkUserExists(userId); + reviewStorage.addRating(reviewId, userId, isUseful); + log.debug("Пользователь id={} оценил отзыв id={}: полезный={}", userId, reviewId, isUseful); + } + + private void removeRating(long reviewId, long userId, boolean isUseful) { + getReviewOrThrow(reviewId); + checkUserExists(userId); + reviewStorage.removeRating(reviewId, userId, isUseful); + log.debug("Пользователь id={} убрал оценку отзыва id={}: полезный={}", userId, reviewId, isUseful); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java index 640cf50..8c13bd2 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java +++ b/src/main/java/ru/yandex/practicum/filmorate/service/UserService.java @@ -4,22 +4,35 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.exception.ValidationException; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Operation; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.film.FilmStorage; import ru.yandex.practicum.filmorate.storage.user.UserStorage; import java.util.Collection; +import java.util.List; @Service @Slf4j public class UserService { private final UserStorage userStorage; + private final FilmStorage filmStorage; + private final FeedService feedService; @Autowired - public UserService(@Qualifier("userDbStorage") UserStorage userStorage) { + public UserService(@Qualifier("userDbStorage") UserStorage userStorage, + @Qualifier("filmDbStorage") FilmStorage filmStorage, + FeedService feedService) { this.userStorage = userStorage; + this.filmStorage = filmStorage; + this.feedService = feedService; } private void validateDifferentUsers(long userId, long otherId) { @@ -36,7 +49,9 @@ private User getUserOrThrow(long id) { .orElseThrow(() -> new NotFoundException("Пользователь с id=" + id + " не найден")); } - // Дружба односторонняя: обратная запись для friendId не создаётся + // Дружба односторонняя: обратная запись для friendId не создаётся. + // Дружба и событие в ленте пишутся вместе: либо оба, либо ни одного + @Transactional public User addFriend(long userId, long friendId) { validateDifferentUsers(userId, friendId); @@ -44,10 +59,12 @@ public User addFriend(long userId, long friendId) { getUserOrThrow(friendId); // проверка существования друга — иначе 404 userStorage.addFriend(userId, friendId); + feedService.addEvent(userId, EventType.FRIEND, Operation.ADD, friendId); log.debug("Пользователь id={} добавил в друзья id={}", userId, friendId); return user; } + @Transactional public User removeFriend(long userId, long friendId) { validateDifferentUsers(userId, friendId); @@ -55,6 +72,7 @@ public User removeFriend(long userId, long friendId) { getUserOrThrow(friendId); // проверка существования друга — иначе 404 userStorage.removeFriend(userId, friendId); + feedService.addEvent(userId, EventType.FRIEND, Operation.REMOVE, friendId); log.debug("Пользователь id={} удалил из друзей id={}", userId, friendId); return user; } @@ -72,6 +90,11 @@ public Collection getCommonFriends(long userId, long otherId) { return userStorage.findCommonFriends(userId, otherId); } + public List getFeed(long userId) { + getUserOrThrow(userId); + return feedService.findByUserId(userId); + } + // Логин не может содержать пробелы private void validateLogin(User user) { if (user.getLogin().contains(" ")) { @@ -95,6 +118,9 @@ public User add(User user) { } public User update(User user) { + if (user.getId() == null) { + throw new ValidationException("Не указан id пользователя"); + } getUserOrThrow(user.getId()); validateLogin(user); @@ -102,6 +128,12 @@ public User update(User user) { return userStorage.update(user); } + // Дружбу, лайки и прочие связи пользователя база удаляет сама — каскадом из schema.sql + public void delete(long id) { + userStorage.delete(id); // нет пользователя — хранилище бросит NotFoundException, это 404 + log.debug("Пользователь id={} удалён", id); + } + public Collection findAll() { return userStorage.findAll(); } @@ -109,4 +141,9 @@ public Collection findAll() { public User findById(long id) { return getUserOrThrow(id); } + + public Collection getRecommendations(long userId) { + getUserOrThrow(userId); + return filmStorage.findRecommendations(userId); + } } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java new file mode 100644 index 0000000..f510957 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorage.java @@ -0,0 +1,73 @@ +package ru.yandex.practicum.filmorate.storage.director; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; + +import java.sql.PreparedStatement; +import java.util.List; +import java.util.Optional; + +@Repository +public class DirectorDbStorage implements DirectorStorage { + + private static final String INSERT_QUERY = "INSERT INTO directors (name) VALUES (?)"; + private static final String UPDATE_QUERY = "UPDATE directors SET name = ? WHERE id = ?"; + private static final String DELETE_QUERY = "DELETE FROM directors WHERE id = ?"; + private static final String SELECT_BY_ID_QUERY = "SELECT id, name FROM directors WHERE id = ?"; + private static final String SELECT_ALL_QUERY = "SELECT id, name FROM directors ORDER BY id"; + + private final JdbcTemplate jdbcTemplate; + private final DirectorRowMapper directorRowMapper; + + @Autowired + public DirectorDbStorage(JdbcTemplate jdbcTemplate, DirectorRowMapper directorRowMapper) { + this.jdbcTemplate = jdbcTemplate; + this.directorRowMapper = directorRowMapper; + } + + @Override + public Director addDirector(Director director) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, new String[]{"id"}); + ps.setString(1, director.getName()); + return ps; + }, keyHolder); + director.setId(keyHolder.getKeyAs(Long.class)); + return director; + } + + @Override + public Director updateDirector(Director director) { + // update возвращает число изменённых строк: 0 — режиссёра с таким id нет + int updatedRows = jdbcTemplate.update(UPDATE_QUERY, director.getName(), director.getId()); + if (updatedRows == 0) { + throw new NotFoundException("Режиссёр с id=" + director.getId() + " не найден"); + } + return director; + } + + // Связи с фильмами база удаляет сама — каскадом из schema.sql + @Override + public void removeDirector(long id) { + if (jdbcTemplate.update(DELETE_QUERY, id) == 0) { + throw new NotFoundException("Режиссёр с id=" + id + " не найден"); + } + } + + @Override + public Optional findDirectorById(long id) { + return jdbcTemplate.query(SELECT_BY_ID_QUERY, directorRowMapper, id).stream().findFirst(); + } + + @Override + public List findAllDirectors() { + return jdbcTemplate.query(SELECT_ALL_QUERY, directorRowMapper); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java new file mode 100644 index 0000000..78ddb80 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/director/DirectorStorage.java @@ -0,0 +1,19 @@ +package ru.yandex.practicum.filmorate.storage.director; + +import ru.yandex.practicum.filmorate.model.Director; + +import java.util.List; +import java.util.Optional; + +public interface DirectorStorage { + + Director addDirector(Director director); + + Director updateDirector(Director director); + + void removeDirector(long id); + + Optional findDirectorById(long id); + + List findAllDirectors(); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java new file mode 100644 index 0000000..a87b0e0 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorage.java @@ -0,0 +1,53 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.storage.mapper.EventRowMapper; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; + +@Repository +public class FeedDbStorage implements FeedStorage { + + private static final String INSERT_QUERY = + "INSERT INTO feed_events (user_id, event_type, operation, entity_id, created_at) VALUES (?, ?, ?, ?, ?)"; + private static final String FIND_BY_USER_QUERY = + "SELECT event_id, user_id, event_type, operation, entity_id, created_at FROM feed_events " + + "WHERE user_id = ? ORDER BY event_id"; + + private final JdbcTemplate jdbc; + private final EventRowMapper mapper; + + @Autowired + public FeedDbStorage(JdbcTemplate jdbc, EventRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public Event add(Event event) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, event.getUserId()); + ps.setString(2, event.getEventType().name()); + ps.setString(3, event.getOperation().name()); + ps.setLong(4, event.getEntityId()); + ps.setLong(5, event.getTimestamp()); + return ps; + }, keyHolder); + event.setEventId(keyHolder.getKeyAs(Long.class)); + return event; + } + + @Override + public List findByUserId(long userId) { + return jdbc.query(FIND_BY_USER_QUERY, mapper, userId); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java new file mode 100644 index 0000000..96471ca --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/feed/FeedStorage.java @@ -0,0 +1,12 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +import ru.yandex.practicum.filmorate.model.Event; + +import java.util.List; + +public interface FeedStorage { + + Event add(Event event); + + List findByUserId(long userId); +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java index df032fd..4381a98 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorage.java @@ -5,23 +5,26 @@ import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Director; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; import java.sql.Date; import java.sql.PreparedStatement; import java.sql.Statement; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; -import java.util.ArrayList; import java.util.Set; -import java.util.Map; -import java.util.HashMap; @Repository public class FilmDbStorage implements FilmStorage { @@ -31,13 +34,30 @@ public class FilmDbStorage implements FilmStorage { "SELECT f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name AS mpa_name " + "FROM films f " + "LEFT JOIN mpa m ON f.mpa_id = m.id "; + // Группировка по всем колонкам SELECT_FILMS — для запросов с JOIN film_likes: + // чтобы посчитать лайки или убрать повторы строк одного фильма + private static final String GROUP_BY_FILM = + "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name "; private static final String FIND_ALL_QUERY = SELECT_FILMS + "ORDER BY f.id"; private static final String FIND_BY_ID_QUERY = SELECT_FILMS + "WHERE f.id = ?"; - private static final String FIND_POPULAR_QUERY = SELECT_FILMS - + "LEFT JOIN film_likes fl ON f.id = fl.film_id " - + "GROUP BY f.id, f.name, f.description, f.release_date, f.duration, f.mpa_id, m.name " - + "ORDER BY COUNT(fl.user_id) DESC, f.id " - + "LIMIT ?"; + + // Базовый запрос для популярных фильмов: WHERE-условия добавляются динамически + private static final String FIND_POPULAR_BASE_QUERY = SELECT_FILMS + + "LEFT JOIN film_likes fl ON f.id = fl.film_id "; + // Хвост запроса популярных фильмов: группировка и сортировка по числу лайков + private static final String FIND_POPULAR_TAIL_QUERY = " " + GROUP_BY_FILM + + "ORDER BY COUNT(fl.user_id) DESC, f.id"; + // Лимит отдельно: общие фильмы собираются тем же запросом, что и популярные, но без лимита + private static final String LIMIT_CLAUSE = " LIMIT ?"; + + // Условия фильтрации популярных фильмов: по жанру и по году выпуска + private static final String FIND_POPULAR_GENRE_FILTER = + "f.id IN (SELECT film_id FROM film_genres WHERE genre_id = ?)"; + private static final String FIND_POPULAR_YEAR_FILTER = + "YEAR(f.release_date) = ?"; + private static final String LIKED_BY_USER_FILTER = + "f.id IN (SELECT film_id FROM film_likes WHERE user_id = ?)"; + private static final String INSERT_QUERY = "INSERT INTO films (name, description, release_date, duration, mpa_id) VALUES (?, ?, ?, ?, ?)"; private static final String UPDATE_QUERY = @@ -62,19 +82,73 @@ public class FilmDbStorage implements FilmStorage { "MERGE INTO film_likes (film_id, user_id) KEY (film_id, user_id) VALUES (?, ?)"; private static final String REMOVE_LIKE_QUERY = "DELETE FROM film_likes WHERE film_id = ? AND user_id = ?"; + private static final String INSERT_FILM_DIRECTOR_QUERY = + "INSERT INTO film_director (film_id, director_id) VALUES (?, ?)"; + private static final String DELETE_DIRECTOR_FILM_QUERY = + "DELETE FROM film_director WHERE film_id = ?"; + private static final String FIND_DIRECTORS_BY_FILM_QUERY = + "SELECT d.id, d.name FROM film_director fd " + + "JOIN directors d ON fd.director_id = d.id " + + "WHERE fd.film_id = ? " + + "ORDER BY d.id"; + // Режиссёры нескольких фильмов одним запросом — так же, как жанры + private static final String FIND_DIRECTORS_BY_FILMS_QUERY = + "SELECT fd.film_id, d.id, d.name FROM film_director fd " + + "JOIN directors d ON fd.director_id = d.id " + + "WHERE fd.film_id IN (%s) " + + "ORDER BY fd.film_id, d.id"; + // Фильмы режиссёра с числом лайков; сортировка добавляется в findFilmByDirector + private static final String FIND_FILMS_BY_DIRECTOR_QUERY = SELECT_FILMS + + "JOIN film_director fd ON f.id = fd.film_id " + + "LEFT JOIN film_likes fl ON f.id = fl.film_id " + + "WHERE fd.director_id = ? " + + GROUP_BY_FILM; + // Поиск: условия по названию и режиссёру добавляются в searchFilms + private static final String FIND_FILMS_BY_SEARCH_QUERY = SELECT_FILMS + + "LEFT JOIN film_director fd ON f.id = fd.film_id " + + "LEFT JOIN directors d ON fd.director_id = d.id " + + "LEFT JOIN film_likes fl ON f.id = fl.film_id "; + + // Рекомендации: максимум пересечений по лайкам среди всех пользователей + private static final String FIND_MAX_OVERLAP_QUERY = + "SELECT MAX(common) AS max_common FROM (" + + "SELECT COUNT(*) AS common FROM film_likes fl1 " + + "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id " + + "WHERE fl1.user_id = ? AND fl2.user_id <> ? " + + "GROUP BY fl2.user_id" + + ") t"; + // Все пользователи, у которых пересечение равно максимуму + private static final String FIND_SIMILAR_USER_IDS_QUERY = + "SELECT fl2.user_id AS other_id FROM film_likes fl1 " + + "JOIN film_likes fl2 ON fl1.film_id = fl2.film_id " + + "WHERE fl1.user_id = ? AND fl2.user_id <> ? " + + "GROUP BY fl2.user_id " + + "HAVING COUNT(*) = ?"; + // Фильмы, которые лайкнули похожие пользователи, но не лайкнул наш. + private static final String FIND_RECOMMENDATIONS_QUERY = SELECT_FILMS + + "JOIN film_likes fl ON f.id = fl.film_id " + + "WHERE fl.user_id IN (%s) " + + "AND f.id NOT IN (SELECT film_id FROM film_likes WHERE user_id = ?) " + + GROUP_BY_FILM + + "ORDER BY f.id"; private final JdbcTemplate jdbc; private final FilmRowMapper mapper; private final GenreRowMapper genreMapper; + private final DirectorRowMapper directorMapper; @Autowired - public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper) { + public FilmDbStorage(JdbcTemplate jdbc, FilmRowMapper mapper, GenreRowMapper genreMapper, + DirectorRowMapper directorMapper) { this.jdbc = jdbc; this.mapper = mapper; this.genreMapper = genreMapper; + this.directorMapper = directorMapper; } + // Фильм, жанры и режиссёры пишутся в три таблицы: либо всё, либо ничего @Override + @Transactional public Film add(Film film) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbc.update(connection -> { @@ -89,15 +163,18 @@ public Film add(Film film) { Long id = keyHolder.getKeyAs(Long.class); saveGenres(id, film.getGenres()); + saveDirectors(id, film.getDirectors()); // Перечитываем из базы: так в ответе будут названия рейтинга и жанров, а не только их id return findById(id).orElseThrow(); } @Override + @Transactional public Film update(Film film) { jdbc.update(UPDATE_QUERY, film.getName(), film.getDescription(), Date.valueOf(film.getReleaseDate()), film.getDuration(), getMpaId(film), film.getId()); saveGenres(film.getId(), film.getGenres()); + saveDirectors(film.getId(), film.getDirectors()); return findById(film.getId()).orElseThrow(); } @@ -113,6 +190,7 @@ public Film delete(long id) { public Collection findAll() { List films = jdbc.query(FIND_ALL_QUERY, mapper); loadGenresForFilms(films); + loadDirectorsForFilms(films); return films; } @@ -120,13 +198,73 @@ public Collection findAll() { public Optional findById(long id) { Optional film = jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); film.ifPresent(this::loadGenres); + film.ifPresent(this::loadDirectors); return film; } @Override - public List findPopular(int count) { - List films = jdbc.query(FIND_POPULAR_QUERY, mapper, count); + public List findPopular(int count, Integer genreId, Integer year) { + List conditions = new ArrayList<>(); + List params = new ArrayList<>(); + + if (genreId != null) { + // Подзапрос вместо JOIN: иначе фильм с несколькими жанрами задвоит строки + conditions.add(FIND_POPULAR_GENRE_FILTER); + params.add(genreId); + } + if (year != null) { + conditions.add(FIND_POPULAR_YEAR_FILTER); + params.add(year); + } + params.add(count); // LIMIT всегда последний + + String sql = buildPopularQuery(conditions) + LIMIT_CLAUSE; + List films = jdbc.query(sql, mapper, params.toArray()); loadGenresForFilms(films); + loadDirectorsForFilms(films); + return films; + } + + @Override + public List findCommon(long userId, long friendId) { + // Одно и то же условие дважды, с разными id: фильм лайкнули оба + String sql = buildPopularQuery(List.of(LIKED_BY_USER_FILTER, LIKED_BY_USER_FILTER)); + List films = jdbc.query(sql, mapper, userId, friendId); + loadGenresForFilms(films); + loadDirectorsForFilms(films); + return films; + } + + @Override + public List findRecommendations(long userId) { + Integer maxOverlap = jdbc.queryForObject( + FIND_MAX_OVERLAP_QUERY, + Integer.class, + userId, userId + ); + if (maxOverlap == null) { + return List.of(); + } + + List similarUserIds = jdbc.query( + FIND_SIMILAR_USER_IDS_QUERY, + (rs, rowNum) -> rs.getLong("other_id"), + userId, userId, maxOverlap + ); + if (similarUserIds.isEmpty()) { + return List.of(); + } + + String placeholders = String.join(", ", Collections.nCopies(similarUserIds.size(), "?")); + String sql = String.format(FIND_RECOMMENDATIONS_QUERY, placeholders); + + List params = new ArrayList<>(similarUserIds); + params.add(userId); // исключаем фильмы, которые уже лайкнул наш пользователь + + List films = jdbc.query(sql, mapper, params.toArray()); + // Жанры и режиссёры подтягиваются тем же путём, что и у остальных списочных запросов + loadGenresForFilms(films); + loadDirectorsForFilms(films); return films; } @@ -141,6 +279,57 @@ public boolean removeLike(long filmId, long userId) { return jdbc.update(REMOVE_LIKE_QUERY, filmId, userId) > 0; } + // sortBy уже проверен в сервисе: year или likes + @Override + public List findFilmByDirector(long directorId, String sortBy) { + String orderBy = "year".equals(sortBy) + ? "ORDER BY f.release_date, f.id" + : "ORDER BY COUNT(fl.user_id) DESC, f.id"; + List films = jdbc.query(FIND_FILMS_BY_DIRECTOR_QUERY + orderBy, mapper, directorId); + loadGenresForFilms(films); + loadDirectorsForFilms(films); + return films; + } + + // by уже проверен в сервисе: title и/или director, строчными + @Override + public List searchFilms(String query, List by) { + List conditions = new ArrayList<>(); + List params = new ArrayList<>(); + String pattern = "%" + query.toLowerCase() + "%"; + + if (by.contains("director")) { + conditions.add("LOWER(d.name) LIKE ?"); + params.add(pattern); + } + if (by.contains("title")) { + conditions.add("LOWER(f.name) LIKE ?"); + params.add(pattern); + } + if (conditions.isEmpty()) { + throw new IllegalArgumentException("Не указано, где искать: title или director"); + } + + // DISTINCT: у фильма с двумя режиссёрами каждый лайк иначе посчитается дважды + String sql = FIND_FILMS_BY_SEARCH_QUERY + + "WHERE " + String.join(" OR ", conditions) + " " + + GROUP_BY_FILM + + "ORDER BY COUNT(DISTINCT fl.user_id) DESC, f.id"; + List films = jdbc.query(sql, mapper, params.toArray()); + loadGenresForFilms(films); + loadDirectorsForFilms(films); + return films; + } + + // Собирает запрос: чтение + WHERE по условиям + сортировка по числу лайков, без LIMIT + private String buildPopularQuery(List conditions) { + StringBuilder sql = new StringBuilder(FIND_POPULAR_BASE_QUERY); + if (!conditions.isEmpty()) { + sql.append("WHERE ").append(String.join(" AND ", conditions)).append(" "); + } + return sql.append(FIND_POPULAR_TAIL_QUERY).toString(); + } + // Рейтинга может не быть — тогда в колонку mpa_id уходит NULL private Integer getMpaId(Film film) { return film.getMpa() == null ? null : film.getMpa().getId(); @@ -179,4 +368,38 @@ private void loadGenresForFilms(List films) { film.getGenres().add(genreMapper.mapRow(rs, rs.getRow())); }, filmsById.keySet().toArray()); } + + // Режиссёры одного фильма + private void loadDirectors(Film film) { + film.getDirectors().addAll(jdbc.query(FIND_DIRECTORS_BY_FILM_QUERY, directorMapper, film.getId())); + } + + // Режиссёры списка фильмов: один запрос только по этим фильмам, раскладываем через Map + private void loadDirectorsForFilms(List films) { + if (films.isEmpty()) { + return; // фильмов нет — запрос не нужен + } + Map filmsById = new HashMap<>(); + for (Film film : films) { + filmsById.put(film.getId(), film); + } + String placeholders = String.join(", ", Collections.nCopies(filmsById.size(), "?")); + jdbc.query(String.format(FIND_DIRECTORS_BY_FILMS_QUERY, placeholders), rs -> { + Film film = filmsById.get(rs.getLong("film_id")); + film.getDirectors().add(directorMapper.mapRow(rs, rs.getRow())); + }, filmsById.keySet().toArray()); + } + + // Заменяет режиссёров фильма: старые связи удаляются, новые вставляются одним пакетом + private void saveDirectors(long filmId, Set directors) { + jdbc.update(DELETE_DIRECTOR_FILM_QUERY, filmId); + if (directors == null || directors.isEmpty()) { + return; + } + List rows = new ArrayList<>(); + for (Director director : directors) { + rows.add(new Object[]{filmId, director.getId()}); + } + jdbc.batchUpdate(INSERT_FILM_DIRECTOR_QUERY, rows); + } } \ 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 index 501bf43..359c492 100644 --- a/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/film/FilmStorage.java @@ -18,9 +18,17 @@ public interface FilmStorage { Optional findById(long id); - List findPopular(int count); + List findPopular(int count, Integer genreId, Integer year); + + List findCommon(long userId, long friendId); + + List findRecommendations(long userId); void addLike(long filmId, long userId); boolean removeLike(long filmId, long userId); + + List findFilmByDirector(long directorId, String sortBy); + + List searchFilms(String query, List by); } \ No newline at end of file diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java new file mode 100644 index 0000000..b27b96e --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/DirectorRowMapper.java @@ -0,0 +1,20 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Director; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class DirectorRowMapper implements RowMapper { + + @Override + public Director mapRow(ResultSet rs, int rowNum) throws SQLException { + Director director = new Director(); + director.setId(rs.getLong("id")); + director.setName(rs.getString("name")); + return director; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java new file mode 100644 index 0000000..2725f64 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/EventRowMapper.java @@ -0,0 +1,26 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class EventRowMapper implements RowMapper { + + @Override + public Event mapRow(ResultSet rs, int rowNum) throws SQLException { + Event event = new Event(); + event.setEventId(rs.getLong("event_id")); + event.setTimestamp(rs.getLong("created_at")); + event.setUserId(rs.getLong("user_id")); + event.setEventType(EventType.valueOf(rs.getString("event_type"))); + event.setOperation(Operation.valueOf(rs.getString("operation"))); + event.setEntityId(rs.getLong("entity_id")); + return event; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java new file mode 100644 index 0000000..0132197 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/mapper/ReviewRowMapper.java @@ -0,0 +1,24 @@ +package ru.yandex.practicum.filmorate.storage.mapper; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; +import ru.yandex.practicum.filmorate.model.Review; + +import java.sql.ResultSet; +import java.sql.SQLException; + +@Component +public class ReviewRowMapper implements RowMapper { + + @Override + public Review mapRow(ResultSet rs, int rowNum) throws SQLException { + Review review = new Review(); + review.setReviewId(rs.getLong("id")); + review.setContent(rs.getString("content")); + review.setIsPositive(rs.getBoolean("is_positive")); + review.setUserId(rs.getLong("user_id")); + review.setFilmId(rs.getLong("film_id")); + review.setUseful(rs.getInt("useful")); + return review; + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java new file mode 100644 index 0000000..a292be8 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorage.java @@ -0,0 +1,96 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.storage.mapper.ReviewRowMapper; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +public class ReviewDbStorage implements ReviewStorage { + + // Рейтинг полезности: лайк +1, дизлайк -1 + private static final String SELECT_REVIEWS = + "SELECT r.id, r.content, r.is_positive, r.user_id, r.film_id, " + + "(SELECT COALESCE(SUM(CASE WHEN rl.is_useful THEN 1 ELSE -1 END), 0) " + + "FROM review_likes rl WHERE rl.review_id = r.id) AS useful " + + "FROM reviews r "; + private static final String FIND_BY_ID_QUERY = SELECT_REVIEWS + "WHERE r.id = ?"; + private static final String FIND_ALL_QUERY = SELECT_REVIEWS + + "ORDER BY useful DESC, r.id LIMIT ?"; + private static final String FIND_BY_FILM_QUERY = SELECT_REVIEWS + + "WHERE r.film_id = ? ORDER BY useful DESC, r.id LIMIT ?"; + private static final String INSERT_QUERY = + "INSERT INTO reviews (content, is_positive, user_id, film_id) VALUES (?, ?, ?, ?)"; + private static final String UPDATE_QUERY = + "UPDATE reviews SET content = ?, is_positive = ? WHERE id = ?"; + private static final String DELETE_QUERY = "DELETE FROM reviews WHERE id = ?"; + private static final String ADD_RATING_QUERY = + "MERGE INTO review_likes (review_id, user_id, is_useful) KEY (review_id, user_id) VALUES (?, ?, ?)"; + private static final String REMOVE_RATING_QUERY = + "DELETE FROM review_likes WHERE review_id = ? AND user_id = ? AND is_useful = ?"; + + private final JdbcTemplate jdbc; + private final ReviewRowMapper mapper; + + @Autowired + public ReviewDbStorage(JdbcTemplate jdbc, ReviewRowMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public Review add(Review review) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, review.getContent()); + ps.setBoolean(2, review.getIsPositive()); + ps.setLong(3, review.getUserId()); + ps.setLong(4, review.getFilmId()); + return ps; + }, keyHolder); + return findById(keyHolder.getKeyAs(Long.class)).orElseThrow(); + } + + @Override + public Review update(Review review) { + jdbc.update(UPDATE_QUERY, review.getContent(), review.getIsPositive(), review.getReviewId()); + return findById(review.getReviewId()).orElseThrow(); + } + + @Override + public void delete(long id) { + jdbc.update(DELETE_QUERY, id); + } + + @Override + public Optional findById(long id) { + return jdbc.query(FIND_BY_ID_QUERY, mapper, id).stream().findFirst(); + } + + @Override + public List findMostUseful(Long filmId, int count) { + if (filmId == null) { + return jdbc.query(FIND_ALL_QUERY, mapper, count); + } + return jdbc.query(FIND_BY_FILM_QUERY, mapper, filmId, count); + } + + @Override + public void addRating(long reviewId, long userId, boolean isUseful) { + jdbc.update(ADD_RATING_QUERY, reviewId, userId, isUseful); + } + + @Override + public void removeRating(long reviewId, long userId, boolean isUseful) { + jdbc.update(REMOVE_RATING_QUERY, reviewId, userId, isUseful); + } +} diff --git a/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java new file mode 100644 index 0000000..5eaa3b4 --- /dev/null +++ b/src/main/java/ru/yandex/practicum/filmorate/storage/review/ReviewStorage.java @@ -0,0 +1,23 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import ru.yandex.practicum.filmorate.model.Review; + +import java.util.List; +import java.util.Optional; + +public interface ReviewStorage { + + Review add(Review review); + + Review update(Review review); + + void delete(long id); + + Optional findById(long id); + + List findMostUseful(Long filmId, int count); + + void addRating(long reviewId, long userId, boolean isUseful); + + void removeRating(long reviewId, long userId, boolean isUseful); +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 3d660c3..becfc96 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -52,4 +52,42 @@ CREATE TABLE IF NOT EXISTS friendship ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, friend_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (user_id, friend_id) -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS reviews ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + content VARCHAR NOT NULL, + is_positive BOOLEAN NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE +); + +-- Оценка отзыва: is_useful = TRUE — лайк, FALSE — дизлайк. Один пользователь — одна оценка. +CREATE TABLE IF NOT EXISTS review_likes ( + review_id BIGINT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_useful BOOLEAN NOT NULL, + PRIMARY KEY (review_id, user_id) +); + +CREATE TABLE IF NOT EXISTS directors ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +-- Связь «фильм — режиссёр», многие ко многим. +CREATE TABLE IF NOT EXISTS film_director ( + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + director_id BIGINT NOT NULL REFERENCES directors(id) ON DELETE CASCADE, + PRIMARY KEY (film_id, director_id) +); + +-- Лента: действия пользователя с лайками, друзьями и отзывами. entity_id — id фильма, друга или отзыва. +CREATE TABLE IF NOT EXISTS feed_events ( + event_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type VARCHAR(10) NOT NULL, + operation VARCHAR(10) NOT NULL, + entity_id BIGINT NOT NULL, + created_at BIGINT NOT NULL +); diff --git a/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java b/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java new file mode 100644 index 0000000..1d70765 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/exception/ErrorHandlerTest.java @@ -0,0 +1,126 @@ +package ru.yandex.practicum.filmorate.exception; + +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// Некорректные и пограничные запросы: раньше часть из них падала с 500. +// Ошибки клиента должны давать 400/404/405/415, корректные запросы — 200 +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase +@Transactional +class ErrorHandlerTest { + + private static final String FILM_JSON = "{\"name\":\"Фильм\",\"description\":\"Описание\"," + + "\"releaseDate\":\"2000-01-01\",\"duration\":100,\"mpa\":{\"id\":1}%s}"; + + private final MockMvc mockMvc; + + @Autowired + ErrorHandlerTest(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + private String film(String extraFields) { + return String.format(FILM_JSON, extraFields); + } + + @Test + void shouldReturn400WhenPathVariableHasWrongType() throws Exception { + mockMvc.perform(get("/films/abc")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenRequiredParameterIsMissing() throws Exception { + mockMvc.perform(get("/films/search").param("query", "фильм")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Не указан обязательный параметр 'by'")); + } + + @Test + void shouldReturn400WhenJsonIsBroken() throws Exception { + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content("{не json")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenUpdatingFilmWithoutId() throws Exception { + mockMvc.perform(put("/films").contentType(MediaType.APPLICATION_JSON).content(film(""))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenUpdatingUserWithoutId() throws Exception { + String user = "{\"email\":\"anna@mail.ru\",\"login\":\"anna\",\"birthday\":\"1990-01-01\"}"; + mockMvc.perform(put("/users").contentType(MediaType.APPLICATION_JSON).content(user)) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenMpaHasNoId() throws Exception { + String filmWithEmptyMpa = film("").replace("{\"id\":1}", "{}"); + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content(filmWithEmptyMpa)) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenSortByIsUnknown() throws Exception { + mockMvc.perform(get("/films/director/1").param("sortBy", "rating")) + .andExpect(status().isBadRequest()); + } + + // Значения by проверяются без учёта регистра — и поиск тоже должен их понимать + @Test + void shouldSearchWhenByIsInAnyCase() throws Exception { + mockMvc.perform(get("/films/search").param("query", "фильм").param("by", "Title,DIRECTOR")) + .andExpect(status().isOk()); + } + + @Test + void shouldReturn404ForUnknownAddress() throws Exception { + mockMvc.perform(get("/unknown")) + .andExpect(status().isNotFound()); + } + + @Test + void shouldReturn415WhenContentTypeIsNotJson() throws Exception { + mockMvc.perform(post("/films").contentType(MediaType.TEXT_PLAIN).content("фильм")) + .andExpect(status().isUnsupportedMediaType()); + } + + @Test + void shouldReturn405WhenMethodIsNotSupported() throws Exception { + mockMvc.perform(post("/films/1")) + .andExpect(status().isMethodNotAllowed()); + } + + // Режиссёр сравнивается по id: дубль в запросе не должен ронять запись связи + @Test + void shouldIgnoreDuplicateDirectorInFilm() throws Exception { + String director = mockMvc.perform(post("/directors") + .contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"Режиссёр\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Integer directorId = JsonPath.read(director, "$.id"); + String directors = ",\"directors\":[{\"id\":" + directorId + "},{\"id\":" + directorId + + ",\"name\":\"Режиссёр\"}]"; + + mockMvc.perform(post("/films").contentType(MediaType.APPLICATION_JSON).content(film(directors))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.directors.length()").value(1)); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java b/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java new file mode 100644 index 0000000..3dff377 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/service/FeedEventsTest.java @@ -0,0 +1,110 @@ +package ru.yandex.practicum.filmorate.service; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.model.User; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureTestDatabase +@Transactional +class FeedEventsTest { + + private final UserService userService; + private final FilmService filmService; + private final ReviewService reviewService; + + @Autowired + FeedEventsTest(UserService userService, FilmService filmService, ReviewService reviewService) { + this.userService = userService; + this.filmService = filmService; + this.reviewService = reviewService; + } + + private User createUser(String login) { + User user = new User(); + user.setEmail(login + "@mail.ru"); + user.setLogin(login); + user.setBirthday(LocalDate.of(1990, 1, 1)); + return userService.add(user); + } + + private Film createFilm() { + Film film = new Film(); + film.setName("Фильм"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film.setDuration(120); + return filmService.add(film); + } + + @Test + void shouldRecordUserActionsInFeed() { + long userId = createUser("anna").getId(); + long friendId = createUser("boris").getId(); + long filmId = createFilm().getId(); + + userService.addFriend(userId, friendId); + userService.removeFriend(userId, friendId); + Review review = new Review(); + review.setContent("Отличный фильм"); + review.setIsPositive(true); + review.setUserId(userId); + review.setFilmId(filmId); + long reviewId = reviewService.add(review).getReviewId(); + review.setReviewId(reviewId); + review.setContent("Так себе фильм"); + reviewService.update(review); + reviewService.delete(reviewId); + filmService.addLike(filmId, userId); + filmService.removeLike(filmId, userId); + + List feed = userService.getFeed(userId); + + assertEquals(List.of( + List.of(EventType.FRIEND, Operation.ADD, friendId), + List.of(EventType.FRIEND, Operation.REMOVE, friendId), + List.of(EventType.REVIEW, Operation.ADD, reviewId), + List.of(EventType.REVIEW, Operation.UPDATE, reviewId), + List.of(EventType.REVIEW, Operation.REMOVE, reviewId), + List.of(EventType.LIKE, Operation.ADD, filmId), + List.of(EventType.LIKE, Operation.REMOVE, filmId) + ), feed.stream().map(e -> List.of(e.getEventType(), e.getOperation(), e.getEntityId())).toList()); + assertTrue(feed.stream().allMatch(e -> e.getUserId() == userId && e.getTimestamp() > 0)); + assertTrue(userService.getFeed(friendId).isEmpty()); + } + + @Test + void shouldNotRecordReviewRatings() { + long authorId = createUser("anna").getId(); + long raterId = createUser("boris").getId(); + Review review = new Review(); + review.setContent("Отличный фильм"); + review.setIsPositive(true); + review.setUserId(authorId); + review.setFilmId(createFilm().getId()); + long reviewId = reviewService.add(review).getReviewId(); + + reviewService.addLike(reviewId, raterId); + reviewService.addDislike(reviewId, raterId); + + assertTrue(userService.getFeed(raterId).isEmpty()); + } + + @Test + void shouldThrowWhenFeedOfUnknownUser() { + assertThrows(NotFoundException.class, () -> userService.getFeed(9999)); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java new file mode 100644 index 0000000..d00716c --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/director/DirectorDbStorageTest.java @@ -0,0 +1,75 @@ +package ru.yandex.practicum.filmorate.storage.director; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Director; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({DirectorDbStorage.class, DirectorRowMapper.class}) +public class DirectorDbStorageTest { + + private final DirectorDbStorage directorDbStorage; + + @Autowired + public DirectorDbStorageTest(DirectorDbStorage directorDbStorage) { + this.directorDbStorage = directorDbStorage; + } + + private Director createDirector(String directorName) { + Director director = new Director(); + director.setName(directorName); + return director; + } + + @Test + public void addDirector() { + Director director = createDirector("name"); + directorDbStorage.addDirector(director); + assertEquals("name", director.getName()); + assertNotNull(director.getId()); + } + + @Test + public void getDirectorById() { + Director director = createDirector("director"); + Director createdDirector = directorDbStorage.addDirector(director); + Director retrievedDirector = directorDbStorage.findDirectorById(createdDirector.getId()).orElse(null); + assertEquals(createdDirector.getId(), retrievedDirector.getId()); + assertEquals(createdDirector.getName(), retrievedDirector.getName()); + } + + @Test + public void deleteDirectorById() { + Director director = createDirector("director"); + directorDbStorage.addDirector(director); + directorDbStorage.removeDirector(director.getId()); + assertTrue(directorDbStorage.findDirectorById(director.getId()).isEmpty()); + } + + @Test + public void updateDirectorById() { + Director director = createDirector("director"); + directorDbStorage.addDirector(director); + director.setName("updated name"); + directorDbStorage.updateDirector(director); + Director updatedDirector = directorDbStorage.findDirectorById(director.getId()).orElse(null); + assertEquals("updated name", updatedDirector.getName()); + } + + @Test + public void getAllDirectors() { + Director director = createDirector("director"); + Director director2 = createDirector("director2"); + directorDbStorage.addDirector(director); + directorDbStorage.addDirector(director2); + List allDirectors = directorDbStorage.findAllDirectors(); + assertEquals(2, allDirectors.size()); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java new file mode 100644 index 0000000..6da9dab --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/feed/FeedDbStorageTest.java @@ -0,0 +1,80 @@ +package ru.yandex.practicum.filmorate.storage.feed; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Event; +import ru.yandex.practicum.filmorate.model.EventType; +import ru.yandex.practicum.filmorate.model.Operation; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.mapper.EventRowMapper; +import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; +import ru.yandex.practicum.filmorate.storage.user.UserDbStorage; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({FeedDbStorage.class, EventRowMapper.class, UserDbStorage.class, UserRowMapper.class}) +class FeedDbStorageTest { + + private final FeedDbStorage feedStorage; + private final UserDbStorage userStorage; + + @Autowired + FeedDbStorageTest(FeedDbStorage feedStorage, UserDbStorage userStorage) { + this.feedStorage = feedStorage; + this.userStorage = userStorage; + } + + 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 userStorage.add(user); + } + + private Event addEvent(long userId, EventType eventType, Operation operation, long entityId) { + Event event = new Event(); + event.setTimestamp(1_700_000_000_000L); + event.setUserId(userId); + event.setEventType(eventType); + event.setOperation(operation); + event.setEntityId(entityId); + return feedStorage.add(event); + } + + @Test + void shouldReturnUserEventsInOrderOfAdding() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Event first = addEvent(anna.getId(), EventType.FRIEND, Operation.ADD, boris.getId()); + addEvent(boris.getId(), EventType.LIKE, Operation.ADD, 1); + Event second = addEvent(anna.getId(), EventType.REVIEW, Operation.UPDATE, 5); + + assertNotNull(first.getEventId()); + assertEquals(List.of(first, second), feedStorage.findByUserId(anna.getId())); + } + + @Test + void shouldReturnEmptyFeedForUserWithoutEvents() { + assertTrue(feedStorage.findByUserId(createUser("anna").getId()).isEmpty()); + } + + @Test + void shouldDeleteEventsWithUser() { + User anna = createUser("anna"); + addEvent(anna.getId(), EventType.LIKE, Operation.ADD, 1); + + userStorage.delete(anna.getId()); + + assertTrue(feedStorage.findByUserId(anna.getId()).isEmpty()); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java index 002d03a..a0ec136 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/film/FilmDbStorageTest.java @@ -5,10 +5,14 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.exception.NotFoundException; +import ru.yandex.practicum.filmorate.model.Director; import ru.yandex.practicum.filmorate.model.Film; import ru.yandex.practicum.filmorate.model.Genre; import ru.yandex.practicum.filmorate.model.Mpa; import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.director.DirectorDbStorage; +import ru.yandex.practicum.filmorate.storage.mapper.DirectorRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.FilmRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.GenreRowMapper; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; @@ -23,16 +27,19 @@ @JdbcTest @AutoConfigureTestDatabase @Import({FilmDbStorage.class, FilmRowMapper.class, GenreRowMapper.class, - UserDbStorage.class, UserRowMapper.class}) + UserDbStorage.class, UserRowMapper.class, + DirectorDbStorage.class, DirectorRowMapper.class}) class FilmDbStorageTest { private final FilmDbStorage filmStorage; private final UserDbStorage userStorage; + private final DirectorDbStorage directorStorage; @Autowired - FilmDbStorageTest(FilmDbStorage filmStorage, UserDbStorage userStorage) { + FilmDbStorageTest(FilmDbStorage filmStorage, UserDbStorage userStorage, DirectorDbStorage directorStorage) { this.filmStorage = filmStorage; this.userStorage = userStorage; + this.directorStorage = directorStorage; } // Фильм без рейтинга и жанров: тесты добавляют их сами, где нужно @@ -74,6 +81,12 @@ private static List genreNames(Film film) { return film.getGenres().stream().map(Genre::getName).toList(); } + private Director createDirector(String directorName) { + Director director = new Director(); + director.setName(directorName); + return director; + } + @Test void shouldAddFilmWithMpaAndGenres() { Film film = newFilm("С жанрами"); @@ -135,6 +148,41 @@ void shouldDeleteFilm() { assertTrue(filmStorage.findById(film.getId()).isEmpty()); } + // Без ON DELETE CASCADE удаление упало бы на внешних ключах film_genres и film_likes + @Test + void shouldDeleteFilmWithGenresAndLikes() { + Film film = newFilm("С жанром и лайком"); + film.getGenres().add(genre(1)); + film = filmStorage.add(film); + User anna = createUser("anna"); + filmStorage.addLike(film.getId(), anna.getId()); + + filmStorage.delete(film.getId()); + + assertTrue(filmStorage.findById(film.getId()).isEmpty()); + assertTrue(filmStorage.findPopular(10, null, null).isEmpty()); + } + + @Test + void shouldNotCountLikesOfDeletedUser() { + Film first = createFilm("Без лайков"); + Film liked = createFilm("С лайком"); + User boris = createUser("boris"); + filmStorage.addLike(liked.getId(), boris.getId()); + assertEquals(liked.getId(), filmStorage.findPopular(10, null, null).get(0).getId()); + + userStorage.delete(boris.getId()); + + // Лайков не осталось ни у кого — фильмы идут по порядку id + List ids = filmStorage.findPopular(10, null, null).stream().map(Film::getId).toList(); + assertEquals(List.of(first.getId(), liked.getId()), ids); + } + + @Test + void shouldThrowWhenDeletingUnknownFilm() { + assertThrows(NotFoundException.class, () -> filmStorage.delete(9999)); + } + @Test void shouldAddAndRemoveLike() { Film film = createFilm("Любимый"); @@ -158,10 +206,268 @@ void shouldFindPopularByLikes() { filmStorage.addLike(second.getId(), boris.getId()); filmStorage.addLike(first.getId(), anna.getId()); - List popular = filmStorage.findPopular(10); + List popular = filmStorage.findPopular(10, null, null); assertEquals(List.of(second.getId(), first.getId(), third.getId()), popular.stream().map(Film::getId).toList()); - assertEquals(2, filmStorage.findPopular(2).size()); + assertEquals(2, filmStorage.findPopular(2, null, null).size()); + } + + @Test + void shouldFilterPopularByGenre() { + Film comedy = createFilm("Комедия"); + comedy.getGenres().add(genre(1)); + filmStorage.update(comedy); + Film drama = createFilm("Драма"); + drama.getGenres().add(genre(2)); + filmStorage.update(drama); + User anna = createUser("anna"); + filmStorage.addLike(comedy.getId(), anna.getId()); + filmStorage.addLike(drama.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, 1, null); + + assertEquals(List.of(comedy.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldFilterPopularByYear() { + Film old = createFilm("Старый"); + Film fresh = newFilm("Новый"); + fresh.setReleaseDate(LocalDate.of(2020, 1, 1)); + fresh = filmStorage.add(fresh); + User anna = createUser("anna"); + filmStorage.addLike(old.getId(), anna.getId()); + filmStorage.addLike(fresh.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, null, 2000); + + assertEquals(List.of(old.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldFilterPopularByGenreAndYear() { + Film comedy2000 = newFilm("Комедия 2000"); + comedy2000.getGenres().add(genre(1)); + comedy2000 = filmStorage.add(comedy2000); + Film comedy2020 = newFilm("Комедия 2020"); + comedy2020.setReleaseDate(LocalDate.of(2020, 1, 1)); + comedy2020.getGenres().add(genre(1)); + comedy2020 = filmStorage.add(comedy2020); + User anna = createUser("anna"); + filmStorage.addLike(comedy2000.getId(), anna.getId()); + filmStorage.addLike(comedy2020.getId(), anna.getId()); + + List popular = filmStorage.findPopular(10, 1, 2000); + + assertEquals(List.of(comedy2000.getId()), popular.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnAllPopularWhenFiltersAreNull() { + Film film = createFilm("Один"); + User anna = createUser("anna"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertEquals(1, filmStorage.findPopular(10, null, null).size()); + } + + @Test + void shouldFindCommonFilmsSortedByPopularity() { + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film onlyAnna = createFilm("Только Анны"); + User anna = createUser("anna"); + User boris = createUser("boris"); + User vera = createUser("vera"); + // Оба лайкнули первый и второй; у второго есть ещё лайк Веры — он популярнее + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), anna.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + filmStorage.addLike(second.getId(), vera.getId()); + // Этот лайкнула только Анна — в общие не попадает + filmStorage.addLike(onlyAnna.getId(), anna.getId()); + + List common = filmStorage.findCommon(anna.getId(), boris.getId()); + + assertEquals(List.of(second.getId(), first.getId()), + common.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnEmptyCommonFilmsWhenNoSharedLikes() { + Film film = createFilm("Один"); + User anna = createUser("anna"); + User boris = createUser("boris"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertEquals(0, filmStorage.findCommon(anna.getId(), boris.getId()).size()); + } + + // Рекомендации: фильм похожего пользователя, которого нет у нашего + @Test + void shouldReturnRecommendationsFromMostSimilarUser() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + assertEquals(List.of(second.getId()), + recommendations.stream().map(Film::getId).toList()); + } + + // Жанры в рекомендациях подтягиваются — это то, чего не хватало в UserDbStorage + @Test + void shouldReturnRecommendationsWithGenres() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film first = createFilm("Первый"); + Film second = newFilm("Второй"); + second.getGenres().add(genre(1)); + second = filmStorage.add(second); + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(second.getId(), boris.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + assertEquals(1, recommendations.size()); + assertEquals(List.of("Комедия"), genreNames(recommendations.getFirst())); + } + + @Test + void shouldReturnEmptyRecommendationsWhenNoSimilarUsers() { + User anna = createUser("anna"); + createUser("boris"); + Film film = createFilm("Фильм"); + filmStorage.addLike(film.getId(), anna.getId()); + + assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty()); + } + + @Test + void shouldReturnEmptyWhenSimilarUserLikedNothingNew() { + User anna = createUser("anna"); + User boris = createUser("boris"); + Film film = createFilm("Фильм"); + filmStorage.addLike(film.getId(), anna.getId()); + filmStorage.addLike(film.getId(), boris.getId()); + + assertTrue(filmStorage.findRecommendations(anna.getId()).isEmpty()); + } + + // При равном пересечении учитываем всех «похожих», а не только одного + @Test + void shouldUseAllSimilarUsersWithMaxOverlap() { + User anna = createUser("anna"); + User boris = createUser("boris"); + User vera = createUser("vera"); + Film first = createFilm("Первый"); + Film second = createFilm("Второй"); + Film third = createFilm("Третий"); + // У Анны и Бориса пересечение по first, у Анны и Веры — тоже по first + filmStorage.addLike(first.getId(), anna.getId()); + filmStorage.addLike(first.getId(), boris.getId()); + filmStorage.addLike(first.getId(), vera.getId()); + // У Бориса есть свой фильм second, у Веры — third; оба должны попасть в рекомендации + filmStorage.addLike(second.getId(), boris.getId()); + filmStorage.addLike(third.getId(), vera.getId()); + + List recommendations = filmStorage.findRecommendations(anna.getId()); + + // Оба фильма от похожих пользователей, порядок по id + assertEquals(List.of(second.getId(), third.getId()), + recommendations.stream().map(Film::getId).toList()); + } + + @Test + void shouldReturnAllFilmsByDirectorSortedByLikes() { + Director director = createDirector("Christopher Nolan"); + directorStorage.addDirector(director); + Film film1 = newFilm("Фильм 1"); + film1.getDirectors().add(director); + film1 = filmStorage.add(film1); + Film film2 = newFilm("Фильм 2"); + film2.getDirectors().add(director); + film2 = filmStorage.add(film2); + User anna = createUser("anna"); + User boris = createUser("boris"); + filmStorage.addLike(film1.getId(), anna.getId()); + filmStorage.addLike(film2.getId(), anna.getId()); + filmStorage.addLike(film2.getId(), boris.getId()); + + List allFilms = filmStorage.findFilmByDirector(director.getId(), "likes"); + assertEquals(2, allFilms.size()); + assertEquals(film2.getId(), allFilms.get(0).getId()); + assertEquals(film1.getId(), allFilms.get(1).getId()); + } + + @Test + void shouldReturnAllFilmsByDirectorSortedByYear() { + Director director = createDirector("Steven Spielberg"); + directorStorage.addDirector(director); + Film film1 = newFilm("Фильм 1"); + film1.setReleaseDate(LocalDate.of(2000, 1, 1)); + film1.getDirectors().add(director); + film1 = filmStorage.add(film1); + Film film2 = newFilm("Фильм 2"); + film2.setReleaseDate(LocalDate.of(2010, 1, 1)); + film2.getDirectors().add(director); + film2 = filmStorage.add(film2); + Film film3 = newFilm("Фильм 3"); + film3.setReleaseDate(LocalDate.of(2005, 1, 1)); + film3.getDirectors().add(director); + film3 = filmStorage.add(film3); + List allFilms = filmStorage.findFilmByDirector(director.getId(), "year"); + assertEquals(film1.getId(), allFilms.get(0).getId()); + assertEquals(film3.getId(), allFilms.get(1).getId()); + assertEquals(film2.getId(), allFilms.get(2).getId()); + } + + @Test + void searchFilmByTitle() { + Film film = newFilm("Интерстеллар"); + film.setReleaseDate(LocalDate.of(2000, 1, 1)); + film = filmStorage.add(film); + List foundFilms = filmStorage.searchFilms("инт", List.of("title")); + assertEquals(1, foundFilms.size()); + assertEquals(film.getId(), foundFilms.get(0).getId()); + assertEquals("Интерстеллар", foundFilms.get(0).getName()); + } + + @Test + void searchFilmByDirector() { + Director director = createDirector("Кристофер Нолан"); + directorStorage.addDirector(director); + Film film = newFilm("Начало"); + film.getDirectors().add(director); + film = filmStorage.add(film); + List foundFilms = filmStorage.searchFilms("нол", List.of("director")); + assertEquals(1, foundFilms.size()); + assertEquals(film.getId(), foundFilms.get(0).getId()); + assertEquals("Начало", foundFilms.get(0).getName()); + } + + @Test + void searchFilmByTitleAndDirector() { + Director director = createDirector("Кристофер Нолан"); + directorStorage.addDirector(director); + Film film = newFilm("Начало"); + film.getDirectors().add(director); + film = filmStorage.add(film); + Long filmId = film.getId(); + Film film1 = newFilm("Нолан и его фильмы"); + film1 = filmStorage.add(film1); + Long film1Id = film1.getId(); + List foundFilms = filmStorage.searchFilms("нол", List.of("title", "director")); + assertEquals(2, foundFilms.size()); + assertTrue(foundFilms.stream().anyMatch(f -> f.getId().equals(filmId))); + assertTrue(foundFilms.stream().anyMatch(f -> f.getId().equals(film1Id))); } } \ No newline at end of file diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java new file mode 100644 index 0000000..28bace8 --- /dev/null +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/review/ReviewDbStorageTest.java @@ -0,0 +1,182 @@ +package ru.yandex.practicum.filmorate.storage.review; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; +import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.model.Film; +import ru.yandex.practicum.filmorate.model.Review; +import ru.yandex.practicum.filmorate.model.User; +import ru.yandex.practicum.filmorate.storage.film.FilmDbStorage; +import ru.yandex.practicum.filmorate.storage.mapper.*; +import ru.yandex.practicum.filmorate.storage.user.UserDbStorage; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@JdbcTest +@AutoConfigureTestDatabase +@Import({ReviewDbStorage.class, ReviewRowMapper.class, FilmDbStorage.class, FilmRowMapper.class, + GenreRowMapper.class, UserDbStorage.class, UserRowMapper.class, DirectorRowMapper.class}) +class ReviewDbStorageTest { + + private final ReviewDbStorage reviewStorage; + private final FilmDbStorage filmStorage; + private final UserDbStorage userStorage; + + private User author; + private Film film; + + @Autowired + ReviewDbStorageTest(ReviewDbStorage reviewStorage, FilmDbStorage filmStorage, UserDbStorage userStorage) { + this.reviewStorage = reviewStorage; + this.filmStorage = filmStorage; + this.userStorage = userStorage; + } + + @BeforeEach + void setUp() { + author = createUser("author"); + film = createFilm("Фильм"); + } + + 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 userStorage.add(user); + } + + private Film createFilm(String name) { + Film newFilm = new Film(); + newFilm.setName(name); + newFilm.setDescription("Описание"); + newFilm.setReleaseDate(LocalDate.of(2000, 1, 1)); + newFilm.setDuration(120); + return filmStorage.add(newFilm); + } + + private Review createReview(long filmId, String content) { + Review review = new Review(); + review.setContent(content); + review.setIsPositive(true); + review.setUserId(author.getId()); + review.setFilmId(filmId); + return reviewStorage.add(review); + } + + @Test + void shouldAddReviewAndFindById() { + Review review = createReview(film.getId(), "Отличный фильм"); + + assertNotNull(review.getReviewId()); + assertEquals(0, review.getUseful()); + assertEquals(review, reviewStorage.findById(review.getReviewId()).orElseThrow()); + } + + @Test + void shouldReturnEmptyWhenReviewNotFound() { + assertTrue(reviewStorage.findById(9999).isEmpty()); + } + + @Test + void shouldUpdateOnlyContentAndType() { + Review review = createReview(film.getId(), "Отличный фильм"); + User other = createUser("other"); + Review changes = new Review(); + changes.setReviewId(review.getReviewId()); + changes.setContent("Так себе фильм"); + changes.setIsPositive(false); + changes.setUserId(other.getId()); + changes.setFilmId(createFilm("Другой").getId()); + + Review updated = reviewStorage.update(changes); + + assertEquals("Так себе фильм", updated.getContent()); + assertFalse(updated.getIsPositive()); + assertEquals(author.getId(), updated.getUserId()); + assertEquals(film.getId(), updated.getFilmId()); + } + + @Test + void shouldDeleteReview() { + Review review = createReview(film.getId(), "Отличный фильм"); + + reviewStorage.delete(review.getReviewId()); + + assertTrue(reviewStorage.findById(review.getReviewId()).isEmpty()); + } + + @Test + void shouldCountUsefulFromLikesAndDislikes() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + reviewStorage.addRating(reviewId, createUser("first").getId(), true); + reviewStorage.addRating(reviewId, createUser("second").getId(), true); + reviewStorage.addRating(reviewId, createUser("third").getId(), false); + + assertEquals(1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldReplaceLikeWithDislike() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + long userId = createUser("rater").getId(); + reviewStorage.addRating(reviewId, userId, true); + + reviewStorage.addRating(reviewId, userId, false); + + assertEquals(-1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldRemoveOnlyMatchingRating() { + Review review = createReview(film.getId(), "Отличный фильм"); + long reviewId = review.getReviewId(); + long userId = createUser("rater").getId(); + reviewStorage.addRating(reviewId, userId, true); + + reviewStorage.removeRating(reviewId, userId, false); + assertEquals(1, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + + reviewStorage.removeRating(reviewId, userId, true); + assertEquals(0, reviewStorage.findById(reviewId).orElseThrow().getUseful()); + } + + @Test + void shouldSortByUsefulAndFilterByFilm() { + Review first = createReview(film.getId(), "Первый"); + Review second = createReview(film.getId(), "Второй"); + Review third = createReview(film.getId(), "Третий"); + Review otherFilmReview = createReview(createFilm("Другой").getId(), "Про другой фильм"); + reviewStorage.addRating(second.getReviewId(), createUser("rater").getId(), true); + reviewStorage.addRating(first.getReviewId(), createUser("hater").getId(), false); + + List byFilm = reviewStorage.findMostUseful(film.getId(), 10).stream() + .map(Review::getReviewId) + .toList(); + assertEquals(List.of(second.getReviewId(), third.getReviewId(), first.getReviewId()), byFilm); + + assertEquals(4, reviewStorage.findMostUseful(null, 10).size()); + assertEquals(List.of(second.getReviewId(), third.getReviewId()), + reviewStorage.findMostUseful(null, 2).stream().map(Review::getReviewId).toList()); + assertTrue(reviewStorage.findMostUseful(film.getId(), 10).stream() + .noneMatch(review -> review.getReviewId().equals(otherFilmReview.getReviewId()))); + } + + @Test + void shouldDeleteReviewsWithFilm() { + Review review = createReview(film.getId(), "Отличный фильм"); + + filmStorage.delete(film.getId()); + + assertTrue(reviewStorage.findById(review.getReviewId()).isEmpty()); + } +} diff --git a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java index 288802c..24adb41 100644 --- a/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java +++ b/src/test/java/ru/yandex/practicum/filmorate/storage/user/UserDbStorageTest.java @@ -5,6 +5,7 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest; import org.springframework.context.annotation.Import; +import ru.yandex.practicum.filmorate.exception.NotFoundException; import ru.yandex.practicum.filmorate.model.User; import ru.yandex.practicum.filmorate.storage.mapper.UserRowMapper; @@ -80,6 +81,25 @@ void shouldDeleteUser() { assertTrue(userStorage.findById(user.getId()).isEmpty()); } + // Без ON DELETE CASCADE удаление упало бы на внешнем ключе friendship + @Test + void shouldDeleteUserWithFriendships() { + User anna = createUser("anna"); + User boris = createUser("boris"); + userStorage.addFriend(anna.getId(), boris.getId()); + userStorage.addFriend(boris.getId(), anna.getId()); + + userStorage.delete(boris.getId()); + + assertTrue(userStorage.findById(boris.getId()).isEmpty()); + assertTrue(userStorage.findFriends(anna.getId()).isEmpty()); + } + + @Test + void shouldThrowWhenDeletingUnknownUser() { + assertThrows(NotFoundException.class, () -> userStorage.delete(9999)); + } + @Test void shouldAddFriendOneWay() { User anna = createUser("anna");