From 1c7415805807c7af734eec3bed765dd444a36307 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Tue, 23 Dec 2025 13:12:31 +0300 Subject: [PATCH 1/3] 12_1_init_boot_java --- README.md | 152 ++----------- lombok.config | 1 + pom.xml | 154 ++++++++++--- .../javaops/topjava/TopJavaApplication.java | 12 + .../java/ru/javaops/topjava/app/AuthUser.java | 30 +++ .../java/ru/javaops/topjava/app/AuthUtil.java | 20 ++ .../javaops/topjava/app/config/AppConfig.java | 46 ++++ .../topjava/app/config/OpenApiConfig.java | 44 ++++ .../app/config/RestExceptionHandler.java | 141 ++++++++++++ .../topjava/app/config/SecurityConfig.java | 63 ++++++ .../topjava/common/BaseRepository.java | 31 +++ .../java/ru/javaops/topjava/common/HasId.java | 21 ++ .../javaops/topjava/common/HasIdAndEmail.java | 5 + .../topjava/common/error/AppException.java | 14 ++ .../common/error/DataConflictException.java | 9 + .../topjava/common/error/ErrorType.java | 22 ++ .../error/IllegalRequestDataException.java | 9 + .../common/error/NotFoundException.java | 9 + .../topjava/common/model/BaseEntity.java | 41 ++++ .../topjava/common/model/NamedEntity.java | 35 +++ .../ru/javaops/topjava/common/to/BaseTo.java | 21 ++ .../ru/javaops/topjava/common/to/NamedTo.java | 26 +++ .../common/util/HibernateProxyHelper.java | 24 ++ .../javaops/topjava/common/util/JsonUtil.java | 55 +++++ .../topjava/common/validation/NoHtml.java | 23 ++ .../common/validation/NoHtmlValidator.java | 13 ++ .../common/validation/ValidationUtil.java | 24 ++ .../ru/javaops/topjava/user/model/Role.java | 14 ++ .../ru/javaops/topjava/user/model/User.java | 87 ++++++++ .../user/repository/UserRepository.java | 28 +++ .../ru/javaops/topjava/user/to/UserTo.java | 35 +++ .../javaops/topjava/user/util/UsersUtil.java | 21 ++ .../user/web/AbstractUserController.java | 35 +++ .../topjava/user/web/AdminUserController.java | 77 +++++++ .../topjava/user/web/ProfileController.java | 61 +++++ .../topjava/user/web/UniqueMailValidator.java | 48 ++++ .../java/ru/javawebinar/topjava/Main.java | 11 - src/main/resources/application.yaml | 56 +++++ src/main/resources/data.sql | 9 + .../topjava/AbstractControllerTest.java | 26 +++ .../ru/javaops/topjava/MatcherFactory.java | 83 +++++++ .../ru/javaops/topjava/user/UserTestData.java | 38 ++++ .../user/web/AdminUserControllerTest.java | 209 ++++++++++++++++++ .../user/web/ProfileControllerTest.java | 111 ++++++++++ src/test/resources/application-test.yaml | 1 + 45 files changed, 1826 insertions(+), 169 deletions(-) create mode 100644 lombok.config create mode 100644 src/main/java/ru/javaops/topjava/TopJavaApplication.java create mode 100644 src/main/java/ru/javaops/topjava/app/AuthUser.java create mode 100644 src/main/java/ru/javaops/topjava/app/AuthUtil.java create mode 100644 src/main/java/ru/javaops/topjava/app/config/AppConfig.java create mode 100644 src/main/java/ru/javaops/topjava/app/config/OpenApiConfig.java create mode 100644 src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java create mode 100644 src/main/java/ru/javaops/topjava/app/config/SecurityConfig.java create mode 100644 src/main/java/ru/javaops/topjava/common/BaseRepository.java create mode 100644 src/main/java/ru/javaops/topjava/common/HasId.java create mode 100644 src/main/java/ru/javaops/topjava/common/HasIdAndEmail.java create mode 100644 src/main/java/ru/javaops/topjava/common/error/AppException.java create mode 100644 src/main/java/ru/javaops/topjava/common/error/DataConflictException.java create mode 100644 src/main/java/ru/javaops/topjava/common/error/ErrorType.java create mode 100644 src/main/java/ru/javaops/topjava/common/error/IllegalRequestDataException.java create mode 100644 src/main/java/ru/javaops/topjava/common/error/NotFoundException.java create mode 100644 src/main/java/ru/javaops/topjava/common/model/BaseEntity.java create mode 100644 src/main/java/ru/javaops/topjava/common/model/NamedEntity.java create mode 100644 src/main/java/ru/javaops/topjava/common/to/BaseTo.java create mode 100644 src/main/java/ru/javaops/topjava/common/to/NamedTo.java create mode 100644 src/main/java/ru/javaops/topjava/common/util/HibernateProxyHelper.java create mode 100644 src/main/java/ru/javaops/topjava/common/util/JsonUtil.java create mode 100644 src/main/java/ru/javaops/topjava/common/validation/NoHtml.java create mode 100644 src/main/java/ru/javaops/topjava/common/validation/NoHtmlValidator.java create mode 100644 src/main/java/ru/javaops/topjava/common/validation/ValidationUtil.java create mode 100644 src/main/java/ru/javaops/topjava/user/model/Role.java create mode 100644 src/main/java/ru/javaops/topjava/user/model/User.java create mode 100644 src/main/java/ru/javaops/topjava/user/repository/UserRepository.java create mode 100644 src/main/java/ru/javaops/topjava/user/to/UserTo.java create mode 100644 src/main/java/ru/javaops/topjava/user/util/UsersUtil.java create mode 100644 src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java create mode 100644 src/main/java/ru/javaops/topjava/user/web/AdminUserController.java create mode 100644 src/main/java/ru/javaops/topjava/user/web/ProfileController.java create mode 100644 src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java delete mode 100644 src/main/java/ru/javawebinar/topjava/Main.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/data.sql create mode 100644 src/test/java/ru/javaops/topjava/AbstractControllerTest.java create mode 100644 src/test/java/ru/javaops/topjava/MatcherFactory.java create mode 100644 src/test/java/ru/javaops/topjava/user/UserTestData.java create mode 100644 src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java create mode 100644 src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java create mode 100644 src/test/resources/application-test.yaml diff --git a/README.md b/README.md index 6f6211630..6fa176f8e 100644 --- a/README.md +++ b/README.md @@ -11,135 +11,23 @@ Maven/ Spring/ Security/ JPA(Hibernate)/ REST(Jackson)/ Bootstrap(CSS)/ jQuery + - [Wiki IDEA](https://github.com/JavaOPs/topjava/wiki/IDEA) - [Демо разрабатываемого приложения](http://javaops-demo.ru/topjava) -### 25.09: Старт проекта -- Начало проверки [вступительного задания HW0](https://github.com/JavaOPs/topjava#-Домашнее-задание-hw0) - -#### 30.09 Дедлайн на сдачу HW0 -### 02.10: 1-е занятие -- Разбор домашнего задания вступительного занятия (вместе с Optional) -- Обзор используемых в проекте технологий. Интеграция ПО -- Maven -- WAR. Веб-контейнер Tomcat. Сервлеты -- Логирование -- Уровни и зависимости логгирования. JMX -- Домашнее задание 1-го занятия (HW1 + Optional) - -### 09.10: 2-е занятие -- Разбор домашнего задания HW1 + Optional -- Библиотека vs Фреймворк. Стандартные библиотеки Apache Commons, Guava -- Слои приложения. Создание каркаса приложения -- Обзор Spring Framework. Spring Context -- Пояснения к HW2. Обработка Autowired -- Домашнее задание (HW2 + Optional) - -### 16.10: 3-е занятие -- Разбор домашнего задания HW2 + Optional -- Жизненный цикл Spring контекста -- Тестирование через JUnit -- Spring Test -- Базы данных. Обзор NoSQL и Java persistence solution без ORM -- Установка PostgreSQL. Docker -- Настройка Database в IDEA -- Скрипты инициализации базы. Spring Jdbc Template -- Тестирование UserService через AssertJ -- Логирование тестов -- Домашнее задание (HW3 + Optional) - -### 23.10: 4-е занятие -- Разбор домашнего задания HW3 + Optional -- Методы улучшения качества кода -- Spring: инициализация и популирование DB -- Подмена контекста при тестировании -- ORM. Hibernate. JPA -- Поддержка HSQLDB -- Домашнее задание (HW4 + Optional) -#### Начало выполнения [выпускного проекта](https://github.com/JavaOPs/topjava/blob/master/graduation.md) - -### 30.10: 5-е занятие -- Обзор JDK 9/17. Миграция Topjava с 1.8 на 17 -- Разбор вопросов -- Разбор домашнего задания HW4 + Optional -- Транзакции -- Профили Maven и Spring -- Пул коннектов -- Spring Data JPA -- Spring кэш -- Домашнее задание (HW5 + Optional) - -### 06.11: 6-е занятие -- Разбор домашнего задания HW5 + Optional -- Кэш Hibernate -- Spring Web -- JSP, JSTL, internationalization -- Динамическое изменение профиля при запуске -- Конфигурирование Tomcat через maven plugin. Jndi-lookup -- Spring Web MVC -- Spring Internationalization -- Домашнее задание (HW6 + Optional) - -#### Большое ДЗ + выпускной проект + начинаем [курс BootJava](https://javaops.ru/view/bootjava) + подтягиваем "хвосты". - -### 20.11: 7-е занятие -- Разбор домашнего задания HW6 + Optional -- Автогенерация DDL по модели -- Тестирование Spring MVC -- Миграция на JUnit 5 -- Принципы REST. REST контроллеры -- Тестирование REST контроллеров. Jackson -- jackson-datatype-hibernate. Тестирование через матчеры -- Тестирование через SoapUi. UTF-8 -- Домашнее задание (HW7 + Optional) - -### 27.11: 8-е занятие -- Разбор домашнего задания HW7 + Optional -- WebJars. jQuery и JavaScript frameworks -- Bootstrap -- AJAX. Datatables. jQuery -- jQuery notifications plugin -- Добавление Spring Security -- Домашнее задание (HW8 + Optional) - -### 04.12: 9-е занятие -- Разбор домашнего задания HW8 + Optional -- Spring Binding -- Spring Validation -- Перевод DataTables на Ajax -- Форма login / logout -- Реализация собственного провайдера авторицазии -- Принцип работы Spring Security. Проксирование -- Spring Security Test -- Cookie. Session -- Домашнее задание (HW9 + Optional) - -### 11.12: 10-е занятие -- Разбор домашнего задания HW10 + Optional -- Кастомизация JSON (@JsonView) и валидации (groups) -- Рефакторинг: jQuery конверторы и группы валидации по умолчанию -- Spring Security Taglib. Method Security Expressions -- Интерсепторы. Редактирование профиля. JSP tag files -- Форма регистрации -- Обработка исключений в Spring -- Encoding password -- Миграция на Spring 5 -- Защита от межсайтовой подделки запросов (CSRF) -- Домашнее задание (HW10) - -### 18.12: 11-е занятие -- Разбор домашнего задания HW10 + Optional -- Локализация datatables, ошибок валидации -- Защита от XSS (Cross Site Scripting) -- Обработка ошибок 404 (NotFound) -- Доступ к AuthorizedUser -- Ограничение модификации пользователей -- Деплой приложения [на собственный выделенный сервер](https://github.com/JavaOPs/startup) -- Домашнее задание (HW11): сокрытия полей в Swagger -- Составление резюме. Собеседование. Разработка ПО. Возможные доработки приложения - -### 22.12: Миграция на Spring-Boot 3.5 -- Ревью вашего резюме -- Основы Spring Boot. Spring Boot maven plugin -- Lombok, база H2, ApplicationRunner -- Spring Data REST + HATEOAS -- Миграция приложения подсчета калорий на Spring Boot - -### 12.01: Дедлайн на сдачу [выпускного проекта](https://github.com/JavaOPs/topjava/blob/master/graduation.md) +### Миграция TopJava на Spring-Boot + +Финальный код проекта BootJava с миграцией на Spring Boot 4 +Вычекайте в отдельную папку (как отдельный проект) ветку `spring_boot` нашего проекта (так удобнее, не придется постоянно переключаться между ветками): +``` +git clone --branch spring_boot --single-branch https://github.com/JavaWebinar/topjava.git topjava_boot +``` +------------------------------------------------------------- + +- Stack: [JDK 25](http://jdk.java.net/25/), Spring Boot 4.0, Lombok, H2, Caffeine Cache, Swagger/OpenAPI 3.0 +- Run: `mvn spring-boot:run` in root directory. +----------------------------------------------------- +[REST API documentation](http://localhost:8080/) +Креденшелы: + +``` +Admin: admin@gmail.com / admin +User: user@yandex.ru / password +Guest: guest@gmail.com / guest +``` \ No newline at end of file diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..eb6db90e9 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier \ No newline at end of file diff --git a/pom.xml b/pom.xml index 7c9fa6a10..13a10d3d4 100644 --- a/pom.xml +++ b/pom.xml @@ -1,44 +1,146 @@ + + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - - ru.javawebinar - topjava - jar - - 1.0-SNAPSHOT - - Calories Management - https://javaops-demo.ru/topjava + + org.springframework.boot + spring-boot-starter-parent + 3.5.9 + + + ru.javaops + topjava-boot + 1.0 + TopJava Boot + TopJava Spring Boot migration + https://javaops.ru/view/topjava - 1.8 + 21 + 2.8.14 + 1.21.2 UTF-8 UTF-8 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + + com.h2database + h2 + + + org.jsoup + jsoup + ${jsoup.version} + + + org.projectlombok + lombok + true + + + com.google.code.findbugs + annotations + 3.0.1 + compile + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + org.junit.platform + junit-platform-launcher + test + + + - topjava - install org.apache.maven.plugins maven-compiler-plugin - 3.14.1 - ${java.version} - ${java.version} + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + com.google.code.findbugs + annotations + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Dfile.encoding=UTF-8 - - - - - - - - - - \ No newline at end of file + diff --git a/src/main/java/ru/javaops/topjava/TopJavaApplication.java b/src/main/java/ru/javaops/topjava/TopJavaApplication.java new file mode 100644 index 000000000..6a0ae7f2a --- /dev/null +++ b/src/main/java/ru/javaops/topjava/TopJavaApplication.java @@ -0,0 +1,12 @@ +package ru.javaops.topjava; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TopJavaApplication { + + public static void main(String[] args) { + SpringApplication.run(TopJavaApplication.class, args); + } +} diff --git a/src/main/java/ru/javaops/topjava/app/AuthUser.java b/src/main/java/ru/javaops/topjava/app/AuthUser.java new file mode 100644 index 000000000..6bbff54ed --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/AuthUser.java @@ -0,0 +1,30 @@ +package ru.javaops.topjava.app; + +import lombok.Getter; +import org.springframework.lang.NonNull; +import ru.javaops.topjava.user.model.Role; +import ru.javaops.topjava.user.model.User; + +public class AuthUser extends org.springframework.security.core.userdetails.User { + + @Getter + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } + + public boolean hasRole(Role role) { + return user.hasRole(role); + } + + @Override + public String toString() { + return "AuthUser:" + id() + '[' + user.getEmail() + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/app/AuthUtil.java b/src/main/java/ru/javaops/topjava/app/AuthUtil.java new file mode 100644 index 000000000..d035f1949 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/AuthUtil.java @@ -0,0 +1,20 @@ +package ru.javaops.topjava.app; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static java.util.Objects.requireNonNull; + +public class AuthUtil { + public static AuthUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + return (auth.getPrincipal() instanceof AuthUser au) ? au : null; + } + + public static AuthUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } +} diff --git a/src/main/java/ru/javaops/topjava/app/config/AppConfig.java b/src/main/java/ru/javaops/topjava/app/config/AppConfig.java new file mode 100644 index 000000000..7bc434e4d --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/config/AppConfig.java @@ -0,0 +1,46 @@ +package ru.javaops.topjava.app.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; +import lombok.extern.slf4j.Slf4j; +import org.h2.tools.Server; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; +import ru.javaops.topjava.common.util.JsonUtil; + +import java.sql.SQLException; + +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; + +@Configuration +@Slf4j +@EnableCaching +public class AppConfig { + + @Profile("!test") + @Bean(initMethod = "start", destroyMethod = "stop") + Server h2Server() throws SQLException { + log.info("Start H2 TCP server"); + return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); + } + + // https://stackoverflow.com/a/74630129/548473 + @JsonAutoDetect(fieldVisibility = NONE, getterVisibility = ANY) + interface MixIn extends ProblemDetailJacksonMixin { + } + + @Autowired + void configureAndStoreObjectMapper(ObjectMapper objectMapper) { + objectMapper.registerModule(new Hibernate6Module()); + // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 + objectMapper.addMixIn(ProblemDetail.class, MixIn.class); + JsonUtil.setMapper(objectMapper); + } +} diff --git a/src/main/java/ru/javaops/topjava/app/config/OpenApiConfig.java b/src/main/java/ru/javaops/topjava/app/config/OpenApiConfig.java new file mode 100644 index 000000000..c4793c5f7 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/config/OpenApiConfig.java @@ -0,0 +1,44 @@ +package ru.javaops.topjava.app.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +//https://sabljakovich.medium.com/adding-basic-auth-authorization-option-to-openapi-swagger-documentation-java-spring-95abbede27e9 +@SecurityScheme( + name = "basicAuth", + type = SecuritySchemeType.HTTP, + scheme = "basic" +) +@OpenAPIDefinition( + info = @Info( + title = "REST API documentation", + version = "1.0", + description = """ + Spring Boot migration for TopJava application +

Тестовые креденшелы:
+ - user@yandex.ru / password
+ - admin@gmail.com / admin
+ - guest@gmail.com / guest

+ """, + contact = @Contact(url = "https://javaops.ru/#contacts", name = "Grigory Kislin", email = "admin@javaops.ru") + ), + security = @SecurityRequirement(name = "basicAuth") +) +public class OpenApiConfig { + + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("REST API") + .pathsToMatch("/api/**") + .build(); + } +} diff --git a/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java b/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java new file mode 100644 index 000000000..3f889d930 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java @@ -0,0 +1,141 @@ +package ru.javaops.topjava.app.config; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ValidationException; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.NonNull; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import ru.javaops.topjava.common.error.AppException; +import ru.javaops.topjava.common.error.ErrorType; + +import java.io.FileNotFoundException; +import java.net.URI; +import java.nio.file.AccessDeniedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static ru.javaops.topjava.common.error.ErrorType.*; + +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class RestExceptionHandler { + public static final String ERR_PFX = "ERR# "; + + @Getter + private final MessageSource messageSource; + + // https://stackoverflow.com/a/52254601/548473 + static final Map, ErrorType> HTTP_STATUS_MAP = new LinkedHashMap<>() { + { +// more specific first + put(NoResourceFoundException.class, NOT_FOUND); + put(AuthenticationException.class, UNAUTHORIZED); + put(FileNotFoundException.class, NOT_FOUND); + put(NoHandlerFoundException.class, NOT_FOUND); + put(UnsupportedOperationException.class, APP_ERROR); + put(EntityNotFoundException.class, DATA_CONFLICT); + put(DataIntegrityViolationException.class, DATA_CONFLICT); + put(IllegalArgumentException.class, BAD_DATA); + put(ValidationException.class, BAD_REQUEST); + put(HttpRequestMethodNotSupportedException.class, BAD_REQUEST); + put(ServletRequestBindingException.class, BAD_REQUEST); + put(RequestRejectedException.class, BAD_REQUEST); + put(AccessDeniedException.class, FORBIDDEN); + } + }; + + @ExceptionHandler(BindException.class) + ProblemDetail bindException(BindException ex, HttpServletRequest request) { + Map invalidParams = getErrorMap(ex.getBindingResult()); + String path = request.getRequestURI(); + log.warn(ERR_PFX + "BindException with invalidParams {} at request {}", invalidParams, path); + return createProblemDetail(ex, path, BAD_REQUEST, "BindException", Map.of("invalid_params", invalidParams)); + } + + private Map getErrorMap(BindingResult result) { + Map invalidParams = new LinkedHashMap<>(); + for (ObjectError error : result.getGlobalErrors()) { + invalidParams.put(error.getObjectName(), getErrorMessage(error)); + } + for (FieldError error : result.getFieldErrors()) { + invalidParams.put(error.getField(), getErrorMessage(error)); + } + return invalidParams; + } + + private String getErrorMessage(ObjectError error) { + return error.getCode() == null ? error.getDefaultMessage() : + messageSource.getMessage(error.getCode(), error.getArguments(), error.getDefaultMessage(), LocaleContextHolder.getLocale()); + } + + @ExceptionHandler(Exception.class) + ProblemDetail exception(Exception ex, HttpServletRequest request) { + return processException(ex, request, Map.of()); + } + + ProblemDetail processException(@NonNull Throwable ex, HttpServletRequest request, Map additionalParams) { + Optional optType = findErrorType(ex); + Throwable root = getRootCause(ex); + if (optType.isEmpty() && root != ex) { + ex = root; + optType = findErrorType(root); + } + String path = request.getRequestURI(); + if (optType.isPresent()) { + log.error(ERR_PFX + "Exception {} at request {}", ex, path); + return createProblemDetail(ex, path, optType.get(), ex.getMessage(), additionalParams); + } else { + log.error(ERR_PFX + "Exception " + root + " at request " + path, root); + return createProblemDetail(root, path, APP_ERROR, "Exception " + root.getClass().getSimpleName(), additionalParams); + } + } + + private Optional findErrorType(Throwable ex) { + if (ex instanceof AppException aex) { + return Optional.of(aex.getErrorType()); + } + Class exClass = ex.getClass(); + return HTTP_STATUS_MAP.entrySet().stream() + .filter(entry -> entry.getKey().isAssignableFrom(exClass)) + .findAny().map(Map.Entry::getValue); + } + + // https://datatracker.ietf.org/doc/html/rfc7807 + private ProblemDetail createProblemDetail(Throwable ex, String path, ErrorType type, String defaultDetail, @NonNull Map additionalParams) { + ErrorResponse.Builder builder = ErrorResponse.builder(ex, type.status, defaultDetail); + ProblemDetail pd = builder + .title(type.title).instance(URI.create(path)) + .build().updateAndGetBody(messageSource, LocaleContextHolder.getLocale()); + additionalParams.forEach(pd::setProperty); + return pd; + } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + private static Throwable getRootCause(@NonNull Throwable t) { + Throwable rootCause = NestedExceptionUtils.getRootCause(t); + return rootCause != null ? rootCause : t; + } +} diff --git a/src/main/java/ru/javaops/topjava/app/config/SecurityConfig.java b/src/main/java/ru/javaops/topjava/app/config/SecurityConfig.java new file mode 100644 index 000000000..f675b7aa7 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/app/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package ru.javaops.topjava.app.config; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import ru.javaops.topjava.app.AuthUser; +import ru.javaops.topjava.user.model.Role; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.repository.UserRepository; + +import java.util.Optional; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebSecurity +@Slf4j +@AllArgsConstructor +public class SecurityConfig { + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UserRepository userRepository; + + @Bean + PasswordEncoder passwordEncoder() { + return PASSWORD_ENCODER; + } + + @Bean + UserDetailsService userDetailsService() { + return email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }; + } + + //https://stackoverflow.com/a/76538979/548473 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.securityMatcher("/api/**").authorizeHttpRequests(authz -> + authz.requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .requestMatchers(HttpMethod.POST, "/api/profile").anonymous() + .requestMatchers("/", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll() + .requestMatchers("/api/**").authenticated()) + .httpBasic(withDefaults()) + .sessionManagement(smc -> smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/BaseRepository.java b/src/main/java/ru/javaops/topjava/common/BaseRepository.java new file mode 100644 index 000000000..070d6e897 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/BaseRepository.java @@ -0,0 +1,31 @@ +package ru.javaops.topjava.common; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.topjava.common.error.NotFoundException; + +// https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa +@NoRepositoryBean +public interface BaseRepository extends JpaRepository { + + // https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query.spel-expressions + @Transactional + @Modifying + @Query("DELETE FROM #{#entityName} e WHERE e.id=:id") + int delete(int id); + + // https://stackoverflow.com/a/60695301/548473 (existed delete code 204, not existed: 404) + @SuppressWarnings("all") // transaction invoked + default void deleteExisted(int id) { + if (delete(id) == 0) { + throw new NotFoundException("Entity with id=" + id + " not found"); + } + } + + default T getExisted(int id) { + return findById(id).orElseThrow(() -> new NotFoundException("Entity with id=" + id + " not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/HasId.java b/src/main/java/ru/javaops/topjava/common/HasId.java new file mode 100644 index 000000000..9f059c342 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/HasId.java @@ -0,0 +1,21 @@ +package ru.javaops.topjava.common; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.springframework.util.Assert; + +public interface HasId { + Integer getId(); + + void setId(Integer id); + + @JsonIgnore + default boolean isNew() { + return getId() == null; + } + + // doesn't work for hibernate lazy proxy + default int id() { + Assert.notNull(getId(), "Entity must has id"); + return getId(); + } +} diff --git a/src/main/java/ru/javaops/topjava/common/HasIdAndEmail.java b/src/main/java/ru/javaops/topjava/common/HasIdAndEmail.java new file mode 100644 index 000000000..f0d43c9b4 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/HasIdAndEmail.java @@ -0,0 +1,5 @@ +package ru.javaops.topjava.common; + +public interface HasIdAndEmail extends HasId { + String getEmail(); +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/error/AppException.java b/src/main/java/ru/javaops/topjava/common/error/AppException.java new file mode 100644 index 000000000..f5e007101 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/error/AppException.java @@ -0,0 +1,14 @@ +package ru.javaops.topjava.common.error; + +import lombok.Getter; +import org.springframework.lang.NonNull; + +public class AppException extends RuntimeException { + @Getter + private final ErrorType errorType; + + public AppException(@NonNull String message, ErrorType errorType) { + super(message); + this.errorType = errorType; + } +} diff --git a/src/main/java/ru/javaops/topjava/common/error/DataConflictException.java b/src/main/java/ru/javaops/topjava/common/error/DataConflictException.java new file mode 100644 index 000000000..d7e239b18 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/error/DataConflictException.java @@ -0,0 +1,9 @@ +package ru.javaops.topjava.common.error; + +import static ru.javaops.topjava.common.error.ErrorType.DATA_CONFLICT; + +public class DataConflictException extends AppException { + public DataConflictException(String msg) { + super(msg, DATA_CONFLICT); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/error/ErrorType.java b/src/main/java/ru/javaops/topjava/common/error/ErrorType.java new file mode 100644 index 000000000..209dac290 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/error/ErrorType.java @@ -0,0 +1,22 @@ +package ru.javaops.topjava.common.error; + +import org.springframework.http.HttpStatus; + +public enum ErrorType { + APP_ERROR("Application error", HttpStatus.INTERNAL_SERVER_ERROR), + BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_ENTITY), + BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_ENTITY), + DATA_CONFLICT("DataBase conflict", HttpStatus.CONFLICT), + NOT_FOUND("Resource not found", HttpStatus.NOT_FOUND), + AUTH_ERROR("Authorization error", HttpStatus.FORBIDDEN), + UNAUTHORIZED("Request unauthorized", HttpStatus.UNAUTHORIZED), + FORBIDDEN("Request forbidden", HttpStatus.FORBIDDEN); + + ErrorType(String title, HttpStatus status) { + this.title = title; + this.status = status; + } + + public final String title; + public final HttpStatus status; +} diff --git a/src/main/java/ru/javaops/topjava/common/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/topjava/common/error/IllegalRequestDataException.java new file mode 100644 index 000000000..82f07e5c2 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/error/IllegalRequestDataException.java @@ -0,0 +1,9 @@ +package ru.javaops.topjava.common.error; + +import static ru.javaops.topjava.common.error.ErrorType.BAD_REQUEST; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(msg, BAD_REQUEST); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/error/NotFoundException.java b/src/main/java/ru/javaops/topjava/common/error/NotFoundException.java new file mode 100644 index 000000000..226056c00 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/error/NotFoundException.java @@ -0,0 +1,9 @@ +package ru.javaops.topjava.common.error; + +import static ru.javaops.topjava.common.error.ErrorType.NOT_FOUND; + +public class NotFoundException extends AppException { + public NotFoundException(String msg) { + super(msg, NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/model/BaseEntity.java b/src/main/java/ru/javaops/topjava/common/model/BaseEntity.java new file mode 100644 index 000000000..dac75595e --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/model/BaseEntity.java @@ -0,0 +1,41 @@ +package ru.javaops.topjava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import ru.javaops.topjava.common.HasId; + +import static ru.javaops.topjava.common.util.HibernateProxyHelper.getClassWithoutInitializingProxy; + +@MappedSuperclass +// https://stackoverflow.com/a/6084701/548473 +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity implements HasId { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + // https://stackoverflow.com/questions/1638723 + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClassWithoutInitializingProxy(this) != getClassWithoutInitializingProxy(o)) return false; + return getId() != null && getId().equals(((BaseEntity) o).getId()); + } + + @Override + public int hashCode() { + return getClassWithoutInitializingProxy(this).hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + getId(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/model/NamedEntity.java b/src/main/java/ru/javaops/topjava/common/model/NamedEntity.java new file mode 100644 index 000000000..44d9beb15 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/model/NamedEntity.java @@ -0,0 +1,35 @@ +package ru.javaops.topjava.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.javaops.topjava.common.validation.NoHtml; + + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NamedEntity extends BaseEntity { + + @NotBlank + @Size(min = 2, max = 64) + @Column(name = "name", nullable = false) + @NoHtml + protected String name; + + protected NamedEntity(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + name + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/to/BaseTo.java b/src/main/java/ru/javaops/topjava/common/to/BaseTo.java new file mode 100644 index 000000000..24d90f368 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/to/BaseTo.java @@ -0,0 +1,21 @@ +package ru.javaops.topjava.common.to; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.javaops.topjava.common.HasId; + +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Data +public abstract class BaseTo implements HasId { + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } +} diff --git a/src/main/java/ru/javaops/topjava/common/to/NamedTo.java b/src/main/java/ru/javaops/topjava/common/to/NamedTo.java new file mode 100644 index 000000000..34e4aed12 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/to/NamedTo.java @@ -0,0 +1,26 @@ +package ru.javaops.topjava.common.to; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import ru.javaops.topjava.common.validation.NoHtml; + +@Data +@EqualsAndHashCode(callSuper = true) +public class NamedTo extends BaseTo { + @NotBlank + @Size(min = 2, max = 64) + @NoHtml + protected String name; + + public NamedTo(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + name + ']'; + } +} diff --git a/src/main/java/ru/javaops/topjava/common/util/HibernateProxyHelper.java b/src/main/java/ru/javaops/topjava/common/util/HibernateProxyHelper.java new file mode 100644 index 000000000..eae9551b6 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/util/HibernateProxyHelper.java @@ -0,0 +1,24 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package ru.javaops.topjava.common.util; + + +import lombok.experimental.UtilityClass; +import org.hibernate.proxy.HibernateProxy; + +@UtilityClass +public final class HibernateProxyHelper { + + /** + * Get the class of an instance or the underlying class + * of a proxy (without initializing the proxy!) + */ + public static Class getClassWithoutInitializingProxy(Object object) { + return (object instanceof HibernateProxy proxy) ? + proxy.getHibernateLazyInitializer().getPersistentClass() : object.getClass(); + } +} diff --git a/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java b/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java new file mode 100644 index 000000000..b6ecae83d --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java @@ -0,0 +1,55 @@ +package ru.javaops.topjava.common.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import lombok.experimental.UtilityClass; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@UtilityClass +public class JsonUtil { + private static ObjectMapper mapper; + + public static void setMapper(ObjectMapper mapper) { + JsonUtil.mapper = mapper; + } + + public static List readValues(String json, Class clazz) { + ObjectReader reader = mapper.readerFor(clazz); + try { + return reader.readValues(json).readAll(); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); + } + } + + public static T readValue(String json, Class clazz) { + try { + return mapper.readValue(json, clazz); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); + } + } + + public static String writeValue(T obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); + } + } + + public static String writeAdditionProps(T obj, String addName, Object addValue) { + return writeAdditionProps(obj, Map.of(addName, addValue)); + } + + public static String writeAdditionProps(T obj, Map addProps) { + Map map = mapper.convertValue(obj, new TypeReference<>() {}); + map.putAll(addProps); + return writeValue(map); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/common/validation/NoHtml.java b/src/main/java/ru/javaops/topjava/common/validation/NoHtml.java new file mode 100644 index 000000000..271f98cf5 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/validation/NoHtml.java @@ -0,0 +1,23 @@ +package ru.javaops.topjava.common.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = NoHtmlValidator.class) +@Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE}) +@Retention(RUNTIME) +public @interface NoHtml { + String message() default "HTML tags forbidden"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ru/javaops/topjava/common/validation/NoHtmlValidator.java b/src/main/java/ru/javaops/topjava/common/validation/NoHtmlValidator.java new file mode 100644 index 000000000..bd188935e --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/validation/NoHtmlValidator.java @@ -0,0 +1,13 @@ +package ru.javaops.topjava.common.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +public class NoHtmlValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext ctx) { + return value == null || Jsoup.isValid(value, Safelist.none()); + } +} diff --git a/src/main/java/ru/javaops/topjava/common/validation/ValidationUtil.java b/src/main/java/ru/javaops/topjava/common/validation/ValidationUtil.java new file mode 100644 index 000000000..8956b9e5a --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/validation/ValidationUtil.java @@ -0,0 +1,24 @@ +package ru.javaops.topjava.common.validation; + +import lombok.experimental.UtilityClass; +import ru.javaops.topjava.common.HasId; +import ru.javaops.topjava.common.error.IllegalRequestDataException; + +@UtilityClass +public class ValidationUtil { + + public static void checkIsNew(HasId bean) { + if (!bean.isNew()) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must be new (id=null)"); + } + } + + // Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + public static void assureIdConsistent(HasId bean, int id) { + if (bean.isNew()) { + bean.setId(id); + } else if (bean.id() != id) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/model/Role.java b/src/main/java/ru/javaops/topjava/user/model/Role.java new file mode 100644 index 000000000..05ef4498d --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/model/Role.java @@ -0,0 +1,14 @@ +package ru.javaops.topjava.user.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/model/User.java b/src/main/java/ru/javaops/topjava/user/model/User.java new file mode 100644 index 000000000..09f0edcef --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/model/User.java @@ -0,0 +1,87 @@ +package ru.javaops.topjava.user.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.lang.NonNull; +import ru.javaops.topjava.common.HasIdAndEmail; +import ru.javaops.topjava.common.model.NamedEntity; +import ru.javaops.topjava.common.validation.NoHtml; + +import java.util.*; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends NamedEntity implements HasIdAndEmail { +// No session, no needs Serializable + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotBlank + @Size(max = 64) + @NoHtml // https://stackoverflow.com/questions/17480809 + private String email; + + @Column(name = "password", nullable = false) + @NotBlank + @Size(max = 128) + // https://stackoverflow.com/a/12505165/548473 + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") + private boolean enabled = true; + + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date registered = new Date(); + + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_role", + joinColumns = @JoinColumn(name = "user_id"), + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) + @Column(name = "role") + @ElementCollection(fetch = FetchType.EAGER) + private Set roles = EnumSet.noneOf(Role.class); + + public User(User u) { + this(u.id, u.name, u.email, u.password, u.enabled, u.registered, u.roles); + } + + public User(Integer id, String name, String email, String password, Role... roles) { + this(id, name, email, password, true, new Date(), Arrays.asList(roles)); + } + + public User(Integer id, String name, String email, String password, boolean enabled, Date registered, @NonNull Collection roles) { + super(id, name); + this.email = email; + this.password = password; + this.enabled = enabled; + this.registered = registered; + setRoles(roles); + } + + public void setRoles(Collection roles) { + this.roles = roles.isEmpty() ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); + } + + public boolean hasRole(Role role) { + return roles.contains(role); + } + + @Override + public String toString() { + return "User:" + id + '[' + email + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java b/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java new file mode 100644 index 000000000..1a5881649 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java @@ -0,0 +1,28 @@ +package ru.javaops.topjava.user.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.topjava.common.BaseRepository; +import ru.javaops.topjava.common.error.NotFoundException; +import ru.javaops.topjava.user.model.User; + +import java.util.Optional; + +import static ru.javaops.topjava.app.config.SecurityConfig.PASSWORD_ENCODER; + +@Transactional(readOnly = true) +public interface UserRepository extends BaseRepository { + @Query("SELECT u FROM User u WHERE LOWER(u.email) = LOWER(:email)") + Optional findByEmailIgnoreCase(String email); + + @Transactional + default User prepareAndSave(User user) { + user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return save(user); + } + + default User getExistedByEmail(String email) { + return findByEmailIgnoreCase(email).orElseThrow(() -> new NotFoundException("User with email=" + email + " not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/to/UserTo.java b/src/main/java/ru/javaops/topjava/user/to/UserTo.java new file mode 100644 index 000000000..d89736f37 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/to/UserTo.java @@ -0,0 +1,35 @@ +package ru.javaops.topjava.user.to; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.EqualsAndHashCode; +import lombok.Value; +import ru.javaops.topjava.common.HasIdAndEmail; +import ru.javaops.topjava.common.to.NamedTo; +import ru.javaops.topjava.common.validation.NoHtml; + +@Value +@EqualsAndHashCode(callSuper = true) +public class UserTo extends NamedTo implements HasIdAndEmail { + @Email + @NotBlank + @Size(max = 64) + @NoHtml // https://stackoverflow.com/questions/17480809 + String email; + + @NotBlank + @Size(min = 5, max = 32) + String password; + + public UserTo(Integer id, String name, String email, String password) { + super(id, name); + this.email = email; + this.password = password; + } + + @Override + public String toString() { + return "UserTo:" + id + '[' + email + ']'; + } +} diff --git a/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java b/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java new file mode 100644 index 000000000..e093ae2d5 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java @@ -0,0 +1,21 @@ +package ru.javaops.topjava.user.util; + +import lombok.experimental.UtilityClass; +import ru.javaops.topjava.user.model.Role; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.to.UserTo; + +@UtilityClass +public class UsersUtil { + + public static User createNewFromTo(UserTo userTo) { + return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); + } + + public static User updateFromTo(User user, UserTo userTo) { + user.setName(userTo.getName()); + user.setEmail(userTo.getEmail().toLowerCase()); + user.setPassword(userTo.getPassword()); + return user; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java b/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java new file mode 100644 index 000000000..8f814788e --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java @@ -0,0 +1,35 @@ +package ru.javaops.topjava.user.web; + +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.repository.UserRepository; + +import static org.slf4j.LoggerFactory.getLogger; + +public abstract class AbstractUserController { + protected final Logger log = getLogger(getClass()); + + @Autowired + protected UserRepository repository; + + @Autowired + private UniqueMailValidator emailValidator; + + @InitBinder + protected void initBinder(WebDataBinder binder) { + binder.addValidators(emailValidator); + } + + public User get(int id) { + log.info("get {}", id); + return repository.getExisted(id); + } + + public void delete(int id) { + log.info("delete {}", id); + repository.deleteExisted(id); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java new file mode 100644 index 000000000..6c4924f5e --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java @@ -0,0 +1,77 @@ +package ru.javaops.topjava.user.web; + +import jakarta.validation.Valid; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.topjava.user.model.User; + +import java.net.URI; +import java.util.List; + +import static ru.javaops.topjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.topjava.common.validation.ValidationUtil.checkIsNew; + +@RestController +@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +public class AdminUserController extends AbstractUserController { + + static final String REST_URL = "/api/admin/users"; + + @Override + @GetMapping("/{id}") + public User get(@PathVariable int id) { + return super.get(id); + } + + @Override + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + super.delete(id); + } + + @GetMapping + public List getAll() { + log.info("getAll"); + return repository.findAll(Sort.by(Sort.Direction.ASC, "name", "email")); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@Valid @RequestBody User user) { + log.info("create {}", user); + checkIsNew(user); + User created = repository.prepareAndSave(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Valid @RequestBody User user, @PathVariable int id) { + log.info("update {} with id={}", user, id); + assureIdConsistent(user, id); + repository.prepareAndSave(user); + } + + @GetMapping("/by-email") + public User getByEmail(@RequestParam String email) { + log.info("getByEmail {}", email); + return repository.getExistedByEmail(email); + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void enable(@PathVariable int id, @RequestParam boolean enabled) { + log.info(enabled ? "enable {}" : "disable {}", id); + User user = repository.getExisted(id); + user.setEnabled(enabled); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/ProfileController.java b/src/main/java/ru/javaops/topjava/user/web/ProfileController.java new file mode 100644 index 000000000..fb2b11328 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/web/ProfileController.java @@ -0,0 +1,61 @@ +package ru.javaops.topjava.user.web; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.topjava.app.AuthUser; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.to.UserTo; +import ru.javaops.topjava.user.util.UsersUtil; + +import java.net.URI; + +import static ru.javaops.topjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.topjava.common.validation.ValidationUtil.checkIsNew; + +@RestController +@RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +// TODO: cache only most requested data! +public class ProfileController extends AbstractUserController { + static final String REST_URL = "/api/profile"; + + @GetMapping + public User get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); + return authUser.getUser(); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal AuthUser authUser) { + super.delete(authUser.id()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity register(@Valid @RequestBody UserTo userTo) { + log.info("register {}", userTo); + checkIsNew(userTo); + User created = repository.prepareAndSave(UsersUtil.createNewFromTo(userTo)); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL).build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} with id={}", userTo, authUser.id()); + assureIdConsistent(userTo, authUser.id()); + User user = authUser.getUser(); + repository.prepareAndSave(UsersUtil.updateFromTo(user, userTo)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java b/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java new file mode 100644 index 000000000..c1c5f34ef --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java @@ -0,0 +1,48 @@ +package ru.javaops.topjava.user.web; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import ru.javaops.topjava.app.AuthUtil; +import ru.javaops.topjava.common.HasIdAndEmail; +import ru.javaops.topjava.user.repository.UserRepository; + +@Component +@AllArgsConstructor +public class UniqueMailValidator implements org.springframework.validation.Validator { + public static final String EXCEPTION_DUPLICATE_EMAIL = "User with this email already exists"; + + private final UserRepository repository; + private final HttpServletRequest request; + + @Override + public boolean supports(@NonNull Class clazz) { + return HasIdAndEmail.class.isAssignableFrom(clazz); + } + + @Override + public void validate(@NonNull Object target, @NonNull Errors errors) { + HasIdAndEmail user = ((HasIdAndEmail) target); + if (StringUtils.hasText(user.getEmail())) { + repository.findByEmailIgnoreCase(user.getEmail()) + .ifPresent(dbUser -> { + if (request.getMethod().equals("PUT")) { // UPDATE + int dbId = dbUser.id(); + + // it is ok, if update ourselves + if (user.getId() != null && dbId == user.id()) return; + + // Workaround for update with user.id=null in request body + // ValidationUtil.assureIdConsistent called after this validation + String requestURI = request.getRequestURI(); + if (requestURI.endsWith("/" + dbId) || (dbId == AuthUtil.get().id() && requestURI.contains("/profile"))) + return; + } + errors.rejectValue("email", "", EXCEPTION_DUPLICATE_EMAIL); + }); + } + } +} diff --git a/src/main/java/ru/javawebinar/topjava/Main.java b/src/main/java/ru/javawebinar/topjava/Main.java deleted file mode 100644 index 723742bac..000000000 --- a/src/main/java/ru/javawebinar/topjava/Main.java +++ /dev/null @@ -1,11 +0,0 @@ -package ru.javawebinar.topjava; - -/** - * @see Demo application - * @see Initial project - */ -public class Main { - public static void main(String[] args) { - System.out.format("Hello TopJava Enterprise!"); - } -} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 000000000..fa04cd7d7 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,56 @@ +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +spring: + jpa: + show-sql: true + open-in-view: false + # https://stackoverflow.com/a/67678945/548473 + defer-datasource-initialization: true + hibernate: + ddl-auto: create + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: + format_sql: true + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 + datasource: + # ImMemory + url: jdbc:h2:mem:topjava + # tcp: jdbc:h2:tcp://localhost:9092/mem:topjava + # Absolute path + # url: jdbc:h2:C:/projects/bootjava/db/topjava + # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/db/topjava + # Relative path form current dir + # url: jdbc:h2:./db/topjava + # Relative path from home + # url: jdbc:h2:~/topjava + # tcp: jdbc:h2:tcp://localhost:9092/~/topjava + username: sa + password: + + # Jackson Serialization Issue Resolver + jackson.visibility: + field: any + getter: none + setter: none + is-getter: none + + # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#common-application-properties-cache + cache: +# cache-names: users +# caffeine.spec: maximumSize=5000,expireAfterAccess=60s + +logging: + level: + root: WARN + ru.javaops.topjava: DEBUG + org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG + +server.servlet: + encoding: + charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly + enabled: true # Enable http encoding support + force: true + +springdoc.swagger-ui.path: / diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 000000000..2fd104589 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,9 @@ +INSERT INTO USERS (name, email, password) +VALUES ('User', 'user@yandex.ru', '{noop}password'), + ('Admin', 'admin@gmail.com', '{noop}admin'), + ('Guest', 'guest@gmail.com', '{noop}guest'); + +INSERT INTO USER_ROLE (role, user_id) +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/topjava/AbstractControllerTest.java b/src/test/java/ru/javaops/topjava/AbstractControllerTest.java new file mode 100644 index 000000000..4c28fe657 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/AbstractControllerTest.java @@ -0,0 +1,26 @@ +package ru.javaops.topjava; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +@ActiveProfiles("test") +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment +public abstract class AbstractControllerTest { + + @Autowired + private MockMvc mockMvc; + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + return mockMvc.perform(builder); + } +} diff --git a/src/test/java/ru/javaops/topjava/MatcherFactory.java b/src/test/java/ru/javaops/topjava/MatcherFactory.java new file mode 100644 index 000000000..de8ee9d1a --- /dev/null +++ b/src/test/java/ru/javaops/topjava/MatcherFactory.java @@ -0,0 +1,83 @@ +package ru.javaops.topjava; + +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; +import ru.javaops.topjava.common.util.JsonUtil; + +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Factory for creating test matchers. + *

+ * Comparing actual and expected objects via AssertJ + * Support converting json MvcResult to objects for comparation. + */ +public class MatcherFactory { + + public static Matcher usingAssertions(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + return new Matcher<>(clazz, assertion, iterableAssertion); + } + + public static Matcher usingEqualsComparator(Class clazz) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).isEqualTo(e), + (a, e) -> assertThat(a).isEqualTo(e)); + } + + public static Matcher usingIgnoringFieldsComparator(Class clazz, String... fieldsToIgnore) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(e), + (a, e) -> assertThat(a).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(e)); + } + + public static class Matcher { + private final Class clazz; + private final BiConsumer assertion; + private final BiConsumer, Iterable> iterableAssertion; + + private Matcher(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + this.clazz = clazz; + this.assertion = assertion; + this.iterableAssertion = iterableAssertion; + } + + public void assertMatch(T actual, T expected) { + assertion.accept(actual, expected); + } + + @SafeVarargs + public final void assertMatch(Iterable actual, T... expected) { + assertMatch(actual, List.of(expected)); + } + + public void assertMatch(Iterable actual, Iterable expected) { + iterableAssertion.accept(actual, expected); + } + + public ResultMatcher contentJson(T expected) { + return result -> assertMatch(JsonUtil.readValue(getContent(result), clazz), expected); + } + + @SafeVarargs + public final ResultMatcher contentJson(T... expected) { + return contentJson(List.of(expected)); + } + + public ResultMatcher contentJson(Iterable expected) { + return result -> assertMatch(JsonUtil.readValues(getContent(result), clazz), expected); + } + + public T readFromJson(ResultActions action) throws UnsupportedEncodingException { + return JsonUtil.readValue(getContent(action.andReturn()), clazz); + } + + private static String getContent(MvcResult result) throws UnsupportedEncodingException { + return result.getResponse().getContentAsString(); + } + } +} diff --git a/src/test/java/ru/javaops/topjava/user/UserTestData.java b/src/test/java/ru/javaops/topjava/user/UserTestData.java new file mode 100644 index 000000000..72dcc6f37 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/user/UserTestData.java @@ -0,0 +1,38 @@ +package ru.javaops.topjava.user; + +import ru.javaops.topjava.MatcherFactory; +import ru.javaops.topjava.common.util.JsonUtil; +import ru.javaops.topjava.user.model.Role; +import ru.javaops.topjava.user.model.User; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +public class UserTestData { + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "password"); + + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final int GUEST_ID = 3; + public static final int NOT_FOUND = 100; + public static final String USER_MAIL = "user@yandex.ru"; + public static final String ADMIN_MAIL = "admin@gmail.com"; + public static final String GUEST_MAIL = "guest@gmail.com"; + + public static final User user = new User(USER_ID, "User", USER_MAIL, "password", Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); + public static final User guest = new User(GUEST_ID, "Guest", GUEST_MAIL, "guest"); + + public static User getNew() { + return new User(null, "New", "new@gmail.com", "newPass", false, new Date(), Collections.singleton(Role.USER)); + } + + public static User getUpdated() { + return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), List.of(Role.ADMIN)); + } + + public static String jsonWithPassword(User user, String passw) { + return JsonUtil.writeAdditionProps(user, "password", passw); + } +} diff --git a/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java new file mode 100644 index 000000000..fde6d0490 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java @@ -0,0 +1,209 @@ +package ru.javaops.topjava.user.web; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.topjava.AbstractControllerTest; +import ru.javaops.topjava.user.model.Role; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.repository.UserRepository; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javaops.topjava.user.UserTestData.*; +import static ru.javaops.topjava.user.web.AdminUserController.REST_URL; +import static ru.javaops.topjava.user.web.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; + +class AdminUserControllerTest extends AbstractControllerTest { + + private static final String REST_URL_SLASH = REST_URL + '/'; + + @Autowired + private UserRepository repository; + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + ADMIN_ID)) + .andExpect(status().isOk()) + .andDo(print()) + // https://jira.spring.io/browse/SPR-14472 + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + "by-email?email=" + admin.getEmail())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + USER_ID)) + .andDo(print()) + .andExpect(status().isNoContent()); + assertFalse(repository.findById(USER_ID).isPresent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void deleteNotFound() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enableNotFound() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + NOT_FOUND) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getForbidden() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isForbidden()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void update() throws Exception { + User updated = getUpdated(); + updated.setId(null); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "newPass"))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), getUpdated()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createWithLocation() throws Exception { + User newUser = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(newUser, "newPass"))) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(repository.getExisted(newId), newUser); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin, guest, user)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enable() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + USER_ID) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNoContent()); + + assertFalse(repository.getExisted(USER_ID).isEnabled()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createInvalid() throws Exception { + User invalid = new User(null, null, "", "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateInvalid() throws Exception { + User invalid = new User(user); + invalid.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateHtmlUnsafe() throws Exception { + User updated = new User(user); + updated.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateDuplicate() throws Exception { + User updated = new User(user); + updated.setEmail(ADMIN_MAIL); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createDuplicate() throws Exception { + User expected = new User(null, "New", USER_MAIL, "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(expected, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java new file mode 100644 index 000000000..3c4eff024 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java @@ -0,0 +1,111 @@ +package ru.javaops.topjava.user.web; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.topjava.AbstractControllerTest; +import ru.javaops.topjava.common.util.JsonUtil; +import ru.javaops.topjava.user.model.User; +import ru.javaops.topjava.user.repository.UserRepository; +import ru.javaops.topjava.user.to.UserTo; +import ru.javaops.topjava.user.util.UsersUtil; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javaops.topjava.user.UserTestData.*; +import static ru.javaops.topjava.user.web.ProfileController.REST_URL; + +class ProfileControllerTest extends AbstractControllerTest { + + @Autowired + private UserRepository repository; + + @Test + @WithUserDetails(value = USER_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL)) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.findAll(), admin, guest); + } + + @Test + void register() throws Exception { + UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword"); + User newUser = UsersUtil.createNewFromTo(newTo); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(repository.getExisted(newId), newUser); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", USER_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), UsersUtil.updateFromTo(new User(user), updatedTo)); + } + + @Test + void registerInvalid() throws Exception { + UserTo newTo = new UserTo(null, null, null, null); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateInvalid() throws Exception { + UserTo updatedTo = new UserTo(null, null, "password", null); + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateDuplicate() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", ADMIN_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 000000000..be1663221 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1 @@ +spring.cache.type: none \ No newline at end of file From 3e36a7602d78717e29754c5789a89664d624d32b Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Tue, 23 Dec 2025 13:33:03 +0300 Subject: [PATCH 2/3] 12_2_migrate_spring_boot_4 --- pom.xml | 41 +++++++------------ .../java/ru/javaops/topjava/app/AuthUser.java | 2 +- .../javaops/topjava/app/config/AppConfig.java | 29 +++++++++---- .../app/config/RestExceptionHandler.java | 2 +- .../topjava/common/error/AppException.java | 2 +- .../topjava/common/error/ErrorType.java | 4 +- .../javaops/topjava/common/util/JsonUtil.java | 15 ++++--- .../ru/javaops/topjava/user/model/User.java | 2 +- .../topjava/user/web/UniqueMailValidator.java | 2 +- src/main/resources/application.yaml | 18 +++----- .../topjava/AbstractControllerTest.java | 2 +- .../user/web/AdminUserControllerTest.java | 10 ++--- .../user/web/ProfileControllerTest.java | 6 +-- 13 files changed, 63 insertions(+), 72 deletions(-) diff --git a/pom.xml b/pom.xml index 13a10d3d4..e46773498 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.9 + 4.0.1 ru.javaops @@ -16,8 +16,8 @@ https://javaops.ru/view/topjava - 21 - 2.8.14 + 25 + 3.0.0 1.21.2 UTF-8 UTF-8 @@ -41,10 +41,10 @@ spring-boot-starter-security - + - com.fasterxml.jackson.datatype - jackson-datatype-hibernate6 + tools.jackson.datatype + jackson-datatype-hibernate7 @@ -78,21 +78,22 @@ lombok true - - com.google.code.findbugs - annotations - 3.0.1 - compile - + org.springframework.boot spring-boot-starter-test test + - org.springframework.security - spring-security-test + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test test @@ -120,18 +121,6 @@ org.springframework.boot spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - com.google.code.findbugs - annotations - - - diff --git a/src/main/java/ru/javaops/topjava/app/AuthUser.java b/src/main/java/ru/javaops/topjava/app/AuthUser.java index 6bbff54ed..962d9cca4 100644 --- a/src/main/java/ru/javaops/topjava/app/AuthUser.java +++ b/src/main/java/ru/javaops/topjava/app/AuthUser.java @@ -1,7 +1,7 @@ package ru.javaops.topjava.app; import lombok.Getter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import ru.javaops.topjava.user.model.Role; import ru.javaops.topjava.user.model.User; diff --git a/src/main/java/ru/javaops/topjava/app/config/AppConfig.java b/src/main/java/ru/javaops/topjava/app/config/AppConfig.java index 7bc434e4d..3e9a34bce 100644 --- a/src/main/java/ru/javaops/topjava/app/config/AppConfig.java +++ b/src/main/java/ru/javaops/topjava/app/config/AppConfig.java @@ -1,11 +1,9 @@ package ru.javaops.topjava.app.config; import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; +import com.fasterxml.jackson.annotation.PropertyAccessor; import lombok.extern.slf4j.Slf4j; import org.h2.tools.Server; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,6 +11,9 @@ import org.springframework.http.ProblemDetail; import org.springframework.http.converter.json.ProblemDetailJacksonMixin; import ru.javaops.topjava.common.util.JsonUtil; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.datatype.hibernate7.Hibernate7Module; import java.sql.SQLException; @@ -36,11 +37,21 @@ Server h2Server() throws SQLException { interface MixIn extends ProblemDetailJacksonMixin { } - @Autowired - void configureAndStoreObjectMapper(ObjectMapper objectMapper) { - objectMapper.registerModule(new Hibernate6Module()); - // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 - objectMapper.addMixIn(ProblemDetail.class, MixIn.class); - JsonUtil.setMapper(objectMapper); + // https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide#upgrading-jackson + @Bean + ObjectMapper objectMapper() { + ObjectMapper mapper = JsonMapper.builder() + .changeDefaultVisibility(visibilityChecker -> visibilityChecker + .withVisibility(PropertyAccessor.FIELD, ANY) + .withVisibility(PropertyAccessor.GETTER, NONE) + .withVisibility(PropertyAccessor.SETTER, NONE) + .withVisibility(PropertyAccessor.IS_GETTER, NONE) + ) + .addModule(new Hibernate7Module()) + // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 + .addMixIn(ProblemDetail.class, MixIn.class) + .build(); + JsonUtil.setMapper(mapper); + return mapper; } } diff --git a/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java b/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java index 3f889d930..4242d1ee9 100644 --- a/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java +++ b/src/main/java/ru/javaops/topjava/app/config/RestExceptionHandler.java @@ -11,7 +11,7 @@ import org.springframework.core.NestedExceptionUtils; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ProblemDetail; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.firewall.RequestRejectedException; import org.springframework.validation.BindException; diff --git a/src/main/java/ru/javaops/topjava/common/error/AppException.java b/src/main/java/ru/javaops/topjava/common/error/AppException.java index f5e007101..7a0e9d25b 100644 --- a/src/main/java/ru/javaops/topjava/common/error/AppException.java +++ b/src/main/java/ru/javaops/topjava/common/error/AppException.java @@ -1,7 +1,7 @@ package ru.javaops.topjava.common.error; import lombok.Getter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; public class AppException extends RuntimeException { @Getter diff --git a/src/main/java/ru/javaops/topjava/common/error/ErrorType.java b/src/main/java/ru/javaops/topjava/common/error/ErrorType.java index 209dac290..6bf6c6fb6 100644 --- a/src/main/java/ru/javaops/topjava/common/error/ErrorType.java +++ b/src/main/java/ru/javaops/topjava/common/error/ErrorType.java @@ -4,8 +4,8 @@ public enum ErrorType { APP_ERROR("Application error", HttpStatus.INTERNAL_SERVER_ERROR), - BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_ENTITY), - BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_ENTITY), + BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_CONTENT), + BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_CONTENT), DATA_CONFLICT("DataBase conflict", HttpStatus.CONFLICT), NOT_FOUND("Resource not found", HttpStatus.NOT_FOUND), AUTH_ERROR("Authorization error", HttpStatus.FORBIDDEN), diff --git a/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java b/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java index b6ecae83d..4a5c405ad 100644 --- a/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java +++ b/src/main/java/ru/javaops/topjava/common/util/JsonUtil.java @@ -1,12 +1,11 @@ package ru.javaops.topjava.common.util; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; import lombok.experimental.UtilityClass; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; -import java.io.IOException; import java.util.List; import java.util.Map; @@ -22,7 +21,7 @@ public static List readValues(String json, Class clazz) { ObjectReader reader = mapper.readerFor(clazz); try { return reader.readValues(json).readAll(); - } catch (IOException e) { + } catch (JacksonException e) { throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); } } @@ -30,7 +29,7 @@ public static List readValues(String json, Class clazz) { public static T readValue(String json, Class clazz) { try { return mapper.readValue(json, clazz); - } catch (IOException e) { + } catch (JacksonException e) { throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); } } @@ -38,7 +37,7 @@ public static T readValue(String json, Class clazz) { public static String writeValue(T obj) { try { return mapper.writeValueAsString(obj); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); } } diff --git a/src/main/java/ru/javaops/topjava/user/model/User.java b/src/main/java/ru/javaops/topjava/user/model/User.java index 09f0edcef..12d775deb 100644 --- a/src/main/java/ru/javaops/topjava/user/model/User.java +++ b/src/main/java/ru/javaops/topjava/user/model/User.java @@ -10,7 +10,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import ru.javaops.topjava.common.HasIdAndEmail; import ru.javaops.topjava.common.model.NamedEntity; import ru.javaops.topjava.common.validation.NoHtml; diff --git a/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java b/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java index c1c5f34ef..6d09f9325 100644 --- a/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java +++ b/src/main/java/ru/javaops/topjava/user/web/UniqueMailValidator.java @@ -2,7 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; -import org.springframework.lang.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.Errors; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa04cd7d7..8c069ba4f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -29,17 +29,15 @@ spring: username: sa password: - # Jackson Serialization Issue Resolver - jackson.visibility: - field: any - getter: none - setter: none - is-getter: none - # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#common-application-properties-cache cache: # cache-names: users # caffeine.spec: maximumSize=5000,expireAfterAccess=60s + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true logging: level: @@ -47,10 +45,4 @@ logging: ru.javaops.topjava: DEBUG org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG -server.servlet: - encoding: - charset: UTF-8 # Charset of HTTP requests and responses. Added to the "Content-Type" header if not set explicitly - enabled: true # Enable http encoding support - force: true - springdoc.swagger-ui.path: / diff --git a/src/test/java/ru/javaops/topjava/AbstractControllerTest.java b/src/test/java/ru/javaops/topjava/AbstractControllerTest.java index 4c28fe657..5dd1528ed 100644 --- a/src/test/java/ru/javaops/topjava/AbstractControllerTest.java +++ b/src/test/java/ru/javaops/topjava/AbstractControllerTest.java @@ -1,7 +1,7 @@ package ru.javaops.topjava; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; diff --git a/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java index fde6d0490..98879c489 100644 --- a/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java @@ -155,7 +155,7 @@ void createInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "newPass"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -167,7 +167,7 @@ void updateInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -179,7 +179,7 @@ void updateHtmlUnsafe() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -191,7 +191,7 @@ void updateDuplicate() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(updated, "password"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } @@ -203,7 +203,7 @@ void createDuplicate() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(expected, "newPass"))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file diff --git a/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java index 3c4eff024..4ea8bbd6c 100644 --- a/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java @@ -84,7 +84,7 @@ void registerInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(newTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -95,7 +95,7 @@ void updateInvalid() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()); + .andExpect(status().isUnprocessableContent()); } @Test @@ -105,7 +105,7 @@ void updateDuplicate() throws Exception { perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isUnprocessableContent()) .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); } } \ No newline at end of file From b8c58f58866e9841133063e81907fb0f7dfd90b0 Mon Sep 17 00:00:00 2001 From: JavaOPs Date: Tue, 23 Dec 2025 14:30:23 +0300 Subject: [PATCH 3/3] 12_3_add_calories_meals --- .../ru/javaops/topjava/common/util/Util.java | 11 + .../ru/javaops/topjava/user/model/Meal.java | 62 ++++++ .../ru/javaops/topjava/user/model/User.java | 26 ++- .../user/repository/MealRepository.java | 30 +++ .../user/repository/UserRepository.java | 4 + .../topjava/user/service/MealService.java | 21 ++ .../ru/javaops/topjava/user/to/MealTo.java | 28 +++ .../ru/javaops/topjava/user/to/UserTo.java | 9 +- .../topjava/user/util/DateTimeUtil.java | 22 ++ .../javaops/topjava/user/util/MealsUtil.java | 43 ++++ .../javaops/topjava/user/util/UsersUtil.java | 4 +- .../user/web/AbstractUserController.java | 6 + .../topjava/user/web/AdminUserController.java | 5 + .../topjava/user/web/MealController.java | 98 +++++++++ .../topjava/user/web/ProfileController.java | 5 + src/main/resources/data.sql | 21 +- .../ru/javaops/topjava/user/MealTestData.java | 35 ++++ .../ru/javaops/topjava/user/UserTestData.java | 28 ++- .../user/web/AdminUserControllerTest.java | 14 +- .../topjava/user/web/MealControllerTest.java | 188 ++++++++++++++++++ .../user/web/ProfileControllerTest.java | 20 +- 21 files changed, 656 insertions(+), 24 deletions(-) create mode 100644 src/main/java/ru/javaops/topjava/common/util/Util.java create mode 100644 src/main/java/ru/javaops/topjava/user/model/Meal.java create mode 100644 src/main/java/ru/javaops/topjava/user/repository/MealRepository.java create mode 100644 src/main/java/ru/javaops/topjava/user/service/MealService.java create mode 100644 src/main/java/ru/javaops/topjava/user/to/MealTo.java create mode 100644 src/main/java/ru/javaops/topjava/user/util/DateTimeUtil.java create mode 100644 src/main/java/ru/javaops/topjava/user/util/MealsUtil.java create mode 100644 src/main/java/ru/javaops/topjava/user/web/MealController.java create mode 100644 src/test/java/ru/javaops/topjava/user/MealTestData.java create mode 100644 src/test/java/ru/javaops/topjava/user/web/MealControllerTest.java diff --git a/src/main/java/ru/javaops/topjava/common/util/Util.java b/src/main/java/ru/javaops/topjava/common/util/Util.java new file mode 100644 index 000000000..62da0a01f --- /dev/null +++ b/src/main/java/ru/javaops/topjava/common/util/Util.java @@ -0,0 +1,11 @@ +package ru.javaops.topjava.common.util; + +import lombok.experimental.UtilityClass; +import org.jspecify.annotations.Nullable; + +@UtilityClass +public class Util { + public static > boolean isBetweenHalfOpen(T value, @Nullable T start, @Nullable T end) { + return (start == null || value.compareTo(start) >= 0) && (end == null || value.compareTo(end) < 0); + } +} diff --git a/src/main/java/ru/javaops/topjava/user/model/Meal.java b/src/main/java/ru/javaops/topjava/user/model/Meal.java new file mode 100644 index 000000000..1191c2059 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/model/Meal.java @@ -0,0 +1,62 @@ +package ru.javaops.topjava.user.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; +import org.hibernate.validator.constraints.Range; +import ru.javaops.topjava.common.model.BaseEntity; +import ru.javaops.topjava.common.validation.NoHtml; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@Entity +@Table(name = "meal", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "date_time"}, name = "meal_unique_user_datetime_idx")}) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(callSuper = true, exclude = {"user"}) +public class Meal extends BaseEntity { + + @Column(name = "date_time", nullable = false) + @NotNull + private LocalDateTime dateTime; + + @Column(name = "description", nullable = false) + @NotBlank + @Size(min = 2, max = 120) + @NoHtml + private String description; + + @Column(name = "calories", nullable = false) + @NotNull + @Range(min = 10, max = 5000) + private Integer calories; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @JsonIgnore + private User user; + + public Meal(Integer id, LocalDateTime dateTime, String description, int calories) { + super(id); + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + } + + @Schema(hidden = true) + public LocalDate getDate() { + return dateTime.toLocalDate(); + } + + @Schema(hidden = true) + public LocalTime getTime() { + return dateTime.toLocalTime(); + } +} diff --git a/src/main/java/ru/javaops/topjava/user/model/User.java b/src/main/java/ru/javaops/topjava/user/model/User.java index 12d775deb..07b187f19 100644 --- a/src/main/java/ru/javaops/topjava/user/model/User.java +++ b/src/main/java/ru/javaops/topjava/user/model/User.java @@ -1,6 +1,7 @@ package ru.javaops.topjava.user.model; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -11,12 +12,17 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.jspecify.annotations.NonNull; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.validator.constraints.Range; import ru.javaops.topjava.common.HasIdAndEmail; import ru.javaops.topjava.common.model.NamedEntity; import ru.javaops.topjava.common.validation.NoHtml; import java.util.*; +import static ru.javaops.topjava.user.util.UsersUtil.DEFAULT_CALORIES_PER_DAY; + @Entity @Table(name = "users") @Getter @@ -55,18 +61,30 @@ public class User extends NamedEntity implements HasIdAndEmail { @ElementCollection(fetch = FetchType.EAGER) private Set roles = EnumSet.noneOf(Role.class); + @Column(name = "calories_per_day", nullable = false, columnDefinition = "int default 2000") + @Range(min = 10, max = 10000) + private int caloriesPerDay = DEFAULT_CALORIES_PER_DAY; + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "user")//, cascade = CascadeType.REMOVE, orphanRemoval = true) + @OrderBy("dateTime DESC") + @OnDelete(action = OnDeleteAction.CASCADE) //https://stackoverflow.com/a/44988100/548473 + @Schema(hidden = true) + private List meals; + public User(User u) { - this(u.id, u.name, u.email, u.password, u.enabled, u.registered, u.roles); + this(u.id, u.name, u.email, u.password, u.caloriesPerDay, u.enabled, u.registered, u.roles); + this.meals = List.copyOf(u.meals); } - public User(Integer id, String name, String email, String password, Role... roles) { - this(id, name, email, password, true, new Date(), Arrays.asList(roles)); + public User(Integer id, String name, String email, String password, int caloriesPerDay, Role... roles) { + this(id, name, email, password, caloriesPerDay, true, new Date(), Arrays.asList(roles)); } - public User(Integer id, String name, String email, String password, boolean enabled, Date registered, @NonNull Collection roles) { + public User(Integer id, String name, String email, String password, int caloriesPerDay, boolean enabled, Date registered, @NonNull Collection roles) { super(id, name); this.email = email; this.password = password; + this.caloriesPerDay = caloriesPerDay; this.enabled = enabled; this.registered = registered; setRoles(roles); diff --git a/src/main/java/ru/javaops/topjava/user/repository/MealRepository.java b/src/main/java/ru/javaops/topjava/user/repository/MealRepository.java new file mode 100644 index 000000000..8415e4d1e --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/repository/MealRepository.java @@ -0,0 +1,30 @@ + +package ru.javaops.topjava.user.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.topjava.common.BaseRepository; +import ru.javaops.topjava.common.error.DataConflictException; +import ru.javaops.topjava.user.model.Meal; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Transactional(readOnly = true) +public interface MealRepository extends BaseRepository { + + @Query("SELECT m FROM Meal m WHERE m.user.id=:userId ORDER BY m.dateTime DESC") + List getAll(int userId); + + @Query("SELECT m from Meal m WHERE m.user.id=:userId AND m.dateTime >= :startDate AND m.dateTime < :endDate ORDER BY m.dateTime DESC") + List getBetweenHalfOpen(int userId, LocalDateTime startDate, LocalDateTime endDate); + + @Query("SELECT m FROM Meal m WHERE m.id = :id and m.user.id = :userId") + Optional get(int userId, int id); + + default Meal getBelonged(int userId, int id) { + return get(userId, id).orElseThrow( + () -> new DataConflictException("Meal id=" + id + " is not exist or doesn't belong to User id=" + userId)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java b/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java index 1a5881649..fb7b2d1cd 100644 --- a/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java +++ b/src/main/java/ru/javaops/topjava/user/repository/UserRepository.java @@ -15,6 +15,10 @@ public interface UserRepository extends BaseRepository { @Query("SELECT u FROM User u WHERE LOWER(u.email) = LOWER(:email)") Optional findByEmailIgnoreCase(String email); + // https://stackoverflow.com/a/46013654/548473 + @Query("SELECT u FROM User u LEFT JOIN FETCH u.meals WHERE u.id=?1") + Optional getWithMeals(int id); + @Transactional default User prepareAndSave(User user) { user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); diff --git a/src/main/java/ru/javaops/topjava/user/service/MealService.java b/src/main/java/ru/javaops/topjava/user/service/MealService.java new file mode 100644 index 000000000..d83e5bdbf --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/service/MealService.java @@ -0,0 +1,21 @@ +package ru.javaops.topjava.user.service; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.topjava.user.model.Meal; +import ru.javaops.topjava.user.repository.MealRepository; +import ru.javaops.topjava.user.repository.UserRepository; + +@Service +@AllArgsConstructor +public class MealService { + private final MealRepository mealRepository; + private final UserRepository userRepository; + + @Transactional + public Meal save(int userId, Meal meal) { + meal.setUser(userRepository.getExisted(userId)); + return mealRepository.save(meal); + } +} diff --git a/src/main/java/ru/javaops/topjava/user/to/MealTo.java b/src/main/java/ru/javaops/topjava/user/to/MealTo.java new file mode 100644 index 000000000..473467391 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/to/MealTo.java @@ -0,0 +1,28 @@ +package ru.javaops.topjava.user.to; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import ru.javaops.topjava.common.to.BaseTo; + +import java.time.LocalDateTime; + +@Value +@EqualsAndHashCode(callSuper = true) +public class MealTo extends BaseTo { + + LocalDateTime dateTime; + + String description; + + int calories; + + boolean excess; + + public MealTo(Integer id, LocalDateTime dateTime, String description, int calories, boolean excess) { + super(id); + this.dateTime = dateTime; + this.description = description; + this.calories = calories; + this.excess = excess; + } +} diff --git a/src/main/java/ru/javaops/topjava/user/to/UserTo.java b/src/main/java/ru/javaops/topjava/user/to/UserTo.java index d89736f37..5feded369 100644 --- a/src/main/java/ru/javaops/topjava/user/to/UserTo.java +++ b/src/main/java/ru/javaops/topjava/user/to/UserTo.java @@ -2,9 +2,11 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.EqualsAndHashCode; import lombok.Value; +import org.hibernate.validator.constraints.Range; import ru.javaops.topjava.common.HasIdAndEmail; import ru.javaops.topjava.common.to.NamedTo; import ru.javaops.topjava.common.validation.NoHtml; @@ -22,10 +24,15 @@ public class UserTo extends NamedTo implements HasIdAndEmail { @Size(min = 5, max = 32) String password; - public UserTo(Integer id, String name, String email, String password) { + @Range(min = 10, max = 10000) + @NotNull + Integer caloriesPerDay; + + public UserTo(Integer id, String name, String email, String password, int caloriesPerDay) { super(id, name); this.email = email; this.password = password; + this.caloriesPerDay = caloriesPerDay; } @Override diff --git a/src/main/java/ru/javaops/topjava/user/util/DateTimeUtil.java b/src/main/java/ru/javaops/topjava/user/util/DateTimeUtil.java new file mode 100644 index 000000000..a2ce3fc42 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/util/DateTimeUtil.java @@ -0,0 +1,22 @@ +package ru.javaops.topjava.user.util; + +import lombok.experimental.UtilityClass; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@UtilityClass +public class DateTimeUtil { + + // DB doesn't support LocalDate.MIN/MAX + private static final LocalDateTime MIN_DATE = LocalDateTime.of(1, 1, 1, 0, 0); + private static final LocalDateTime MAX_DATE = LocalDateTime.of(3000, 1, 1, 0, 0); + + public static LocalDateTime atStartOfDayOrMin(LocalDate localDate) { + return localDate != null ? localDate.atStartOfDay() : MIN_DATE; + } + + public static LocalDateTime atStartOfNextDayOrMax(LocalDate localDate) { + return localDate != null ? localDate.plusDays(1).atStartOfDay() : MAX_DATE; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/util/MealsUtil.java b/src/main/java/ru/javaops/topjava/user/util/MealsUtil.java new file mode 100644 index 000000000..16e467917 --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/util/MealsUtil.java @@ -0,0 +1,43 @@ +package ru.javaops.topjava.user.util; + +import lombok.experimental.UtilityClass; +import ru.javaops.topjava.common.util.Util; +import ru.javaops.topjava.user.model.Meal; +import ru.javaops.topjava.user.to.MealTo; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@UtilityClass +public class MealsUtil { + + public static List getTos(Collection meals, int caloriesPerDay) { + return filterByPredicate(meals, caloriesPerDay, meal -> true); + } + + public static List getFilteredTos(Collection meals, int caloriesPerDay, LocalTime startTime, LocalTime endTime) { + return filterByPredicate(meals, caloriesPerDay, meal -> Util.isBetweenHalfOpen(meal.getTime(), startTime, endTime)); + } + + public static List filterByPredicate(Collection meals, int caloriesPerDay, Predicate filter) { + Map caloriesSumByDate = meals.stream() + .collect( + Collectors.groupingBy(Meal::getDate, Collectors.summingInt(Meal::getCalories)) +// Collectors.toMap(Meal::getDate, Meal::getCalories, Integer::sum) + ); + + return meals.stream() + .filter(filter) + .map(meal -> createTo(meal, caloriesSumByDate.get(meal.getDate()) > caloriesPerDay)) + .collect(Collectors.toList()); + } + + public static MealTo createTo(Meal meal, boolean excess) { + return new MealTo(meal.getId(), meal.getDateTime(), meal.getDescription(), meal.getCalories(), excess); + } +} diff --git a/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java b/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java index e093ae2d5..42619f631 100644 --- a/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java +++ b/src/main/java/ru/javaops/topjava/user/util/UsersUtil.java @@ -7,14 +7,16 @@ @UtilityClass public class UsersUtil { + public static final int DEFAULT_CALORIES_PER_DAY = 2000; public static User createNewFromTo(UserTo userTo) { - return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); + return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), userTo.getCaloriesPerDay(), Role.USER); } public static User updateFromTo(User user, UserTo userTo) { user.setName(userTo.getName()); user.setEmail(userTo.getEmail().toLowerCase()); + user.setCaloriesPerDay(userTo.getCaloriesPerDay()); user.setPassword(userTo.getPassword()); return user; } diff --git a/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java b/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java index 8f814788e..db63e2be0 100644 --- a/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java +++ b/src/main/java/ru/javaops/topjava/user/web/AbstractUserController.java @@ -2,6 +2,7 @@ import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import ru.javaops.topjava.user.model.User; @@ -32,4 +33,9 @@ public void delete(int id) { log.info("delete {}", id); repository.deleteExisted(id); } + + public ResponseEntity getWithMeals(int id) { + log.info("getWithMeals {}", id); + return ResponseEntity.of(repository.getWithMeals(id)); + } } \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java index 6c4924f5e..e9c8d7752 100644 --- a/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java +++ b/src/main/java/ru/javaops/topjava/user/web/AdminUserController.java @@ -28,6 +28,11 @@ public User get(@PathVariable int id) { return super.get(id); } + @GetMapping("/{id}/with-meals") + public ResponseEntity getWithMeals(@PathVariable int id) { + return super.getWithMeals(id); + } + @Override @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) diff --git a/src/main/java/ru/javaops/topjava/user/web/MealController.java b/src/main/java/ru/javaops/topjava/user/web/MealController.java new file mode 100644 index 000000000..761b45bcb --- /dev/null +++ b/src/main/java/ru/javaops/topjava/user/web/MealController.java @@ -0,0 +1,98 @@ +package ru.javaops.topjava.user.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.topjava.app.AuthUser; +import ru.javaops.topjava.user.model.Meal; +import ru.javaops.topjava.user.repository.MealRepository; +import ru.javaops.topjava.user.service.MealService; +import ru.javaops.topjava.user.to.MealTo; +import ru.javaops.topjava.user.util.MealsUtil; + +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import static ru.javaops.topjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.topjava.common.validation.ValidationUtil.checkIsNew; +import static ru.javaops.topjava.user.util.DateTimeUtil.atStartOfDayOrMin; +import static ru.javaops.topjava.user.util.DateTimeUtil.atStartOfNextDayOrMax; + +@RestController +@RequestMapping(value = MealController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +@AllArgsConstructor +public class MealController { + static final String REST_URL = "/api/profile/meals"; + + private final MealRepository repository; + private final MealService service; + + @GetMapping("/{id}") + public ResponseEntity get(@AuthenticationPrincipal AuthUser authUser, @PathVariable int id) { + log.info("get meal {} for user {}", id, authUser.id()); + return ResponseEntity.of(repository.get(authUser.id(), id)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal AuthUser authUser, @PathVariable int id) { + log.info("delete {} for user {}", id, authUser.id()); + Meal meal = repository.getBelonged(authUser.id(), id); + repository.delete(meal); + } + + @GetMapping + public List getAll(@AuthenticationPrincipal AuthUser authUser) { + log.info("getAll for user {}", authUser.id()); + return MealsUtil.getTos(repository.getAll(authUser.id()), authUser.getUser().getCaloriesPerDay()); + } + + + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@AuthenticationPrincipal AuthUser authUser, @Valid @RequestBody Meal meal, @PathVariable int id) { + int userId = authUser.id(); + log.info("update {} for user {}", meal, userId); + assureIdConsistent(meal, id); + repository.getBelonged(userId, id); + service.save(userId, meal); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@AuthenticationPrincipal AuthUser authUser, @Valid @RequestBody Meal meal) { + int userId = authUser.id(); + log.info("create {} for user {}", meal, userId); + checkIsNew(meal); + Meal created = service.save(userId, meal); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + + @GetMapping("/filter") + public List getBetween(@AuthenticationPrincipal AuthUser authUser, + @RequestParam @Nullable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @Nullable @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) @Schema(type = "LocalTime") LocalTime startTime, + @RequestParam @Nullable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam @Nullable @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) @Schema(type = "LocalTime") LocalTime endTime) { + + int userId = authUser.id(); + log.info("getBetween dates({} - {}) time({} - {}) for user {}", startDate, endDate, startTime, endTime, userId); + List mealsDateFiltered = repository.getBetweenHalfOpen(userId, atStartOfDayOrMin(startDate), atStartOfNextDayOrMax(endDate)); + return MealsUtil.getFilteredTos(mealsDateFiltered, authUser.getUser().getCaloriesPerDay(), startTime, endTime); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/topjava/user/web/ProfileController.java b/src/main/java/ru/javaops/topjava/user/web/ProfileController.java index fb2b11328..84403a6a7 100644 --- a/src/main/java/ru/javaops/topjava/user/web/ProfileController.java +++ b/src/main/java/ru/javaops/topjava/user/web/ProfileController.java @@ -58,4 +58,9 @@ public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal A User user = authUser.getUser(); repository.prepareAndSave(UsersUtil.updateFromTo(user, userTo)); } + + @GetMapping("/with-meals") + public ResponseEntity getWithMeals(@AuthenticationPrincipal AuthUser authUser) { + return super.getWithMeals(authUser.id()); + } } \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 2fd104589..84e67bb9e 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,9 +1,20 @@ -INSERT INTO USERS (name, email, password) -VALUES ('User', 'user@yandex.ru', '{noop}password'), - ('Admin', 'admin@gmail.com', '{noop}admin'), - ('Guest', 'guest@gmail.com', '{noop}guest'); +INSERT INTO USERS (name, email, password, calories_per_day) +VALUES ('User', 'user@yandex.ru', '{noop}password', 2005), + ('Admin', 'admin@gmail.com', '{noop}admin', 1900), + ('Guest', 'guest@gmail.com', '{noop}guest', 2000); INSERT INTO USER_ROLE (role, user_id) VALUES ('USER', 1), ('ADMIN', 2), - ('USER', 2); \ No newline at end of file + ('USER', 2); + +INSERT INTO MEAL (date_time, description, calories, user_id) +VALUES ('2020-01-30 10:00:00', 'Завтрак', 500, 1), + ('2020-01-30 13:00:00', 'Обед', 1000, 1), + ('2020-01-30 20:00:00', 'Ужин', 500, 1), + ('2020-01-31 0:00:00', 'Еда на граничное значение', 100, 1), + ('2020-01-31 10:00:00', 'Завтрак', 500, 1), + ('2020-01-31 13:00:00', 'Обед', 1000, 1), + ('2020-01-31 20:00:00', 'Ужин', 510, 1), + ('2020-01-31 14:00:00', 'Админ ланч', 510, 2), + ('2020-01-31 21:00:00', 'Админ ужин', 1500, 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/topjava/user/MealTestData.java b/src/test/java/ru/javaops/topjava/user/MealTestData.java new file mode 100644 index 000000000..36ba2d773 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/user/MealTestData.java @@ -0,0 +1,35 @@ +package ru.javaops.topjava.user; + +import ru.javaops.topjava.MatcherFactory; +import ru.javaops.topjava.user.model.Meal; +import ru.javaops.topjava.user.to.MealTo; + +import java.time.Month; +import java.util.List; + +import static java.time.LocalDateTime.of; + +public class MealTestData { + public static final MatcherFactory.Matcher MEAL_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(Meal.class, "user"); + public static final int MEAL1_ID = 1; + public static final int ADMIN_MEAL_ID = 8; + public static final Meal meal1 = new Meal(MEAL1_ID, of(2020, Month.JANUARY, 30, 10, 0), "Завтрак", 500); + public static final Meal meal2 = new Meal(MEAL1_ID + 1, of(2020, Month.JANUARY, 30, 13, 0), "Обед", 1000); + public static final Meal meal3 = new Meal(MEAL1_ID + 2, of(2020, Month.JANUARY, 30, 20, 0), "Ужин", 500); + public static final Meal meal4 = new Meal(MEAL1_ID + 3, of(2020, Month.JANUARY, 31, 0, 0), "Еда на граничное значение", 100); + public static final Meal meal5 = new Meal(MEAL1_ID + 4, of(2020, Month.JANUARY, 31, 10, 0), "Завтрак", 500); + public static final Meal meal6 = new Meal(MEAL1_ID + 5, of(2020, Month.JANUARY, 31, 13, 0), "Обед", 1000); + public static final Meal meal7 = new Meal(MEAL1_ID + 6, of(2020, Month.JANUARY, 31, 20, 0), "Ужин", 510); + public static final List meals = List.of(meal7, meal6, meal5, meal4, meal3, meal2, meal1); + public static final Meal adminMeal1 = new Meal(ADMIN_MEAL_ID, of(2020, Month.JANUARY, 31, 14, 0), "Админ ланч", 510); + public static final Meal adminMeal2 = new Meal(ADMIN_MEAL_ID + 1, of(2020, Month.JANUARY, 31, 21, 0), "Админ ужин", 1500); + public static MatcherFactory.Matcher MEAL_TO_MATCHER = MatcherFactory.usingEqualsComparator(MealTo.class); + + public static Meal getNew() { + return new Meal(null, of(2020, Month.FEBRUARY, 1, 18, 0), "Созданный ужин", 300); + } + + public static Meal getUpdated() { + return new Meal(MEAL1_ID, meal1.getDateTime().plusMinutes(2), "Обновленный завтрак", 200); + } +} diff --git a/src/test/java/ru/javaops/topjava/user/UserTestData.java b/src/test/java/ru/javaops/topjava/user/UserTestData.java index 72dcc6f37..a195a053e 100644 --- a/src/test/java/ru/javaops/topjava/user/UserTestData.java +++ b/src/test/java/ru/javaops/topjava/user/UserTestData.java @@ -9,8 +9,19 @@ import java.util.Date; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static ru.javaops.topjava.user.MealTestData.*; + public class UserTestData { - public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "password"); + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "meals", "password"); + public static MatcherFactory.Matcher USER_WITH_MEALS_MATCHER = + MatcherFactory.usingAssertions(User.class, + // No need use ignoringAllOverriddenEquals, see https://assertj.github.io/doc/#breaking-changes + (a, e) -> assertThat(a).usingRecursiveComparison() + .ignoringFields("registered", "meals.user", "password").isEqualTo(e), + (a, e) -> { + throw new UnsupportedOperationException(); + }); public static final int USER_ID = 1; public static final int ADMIN_ID = 2; @@ -20,16 +31,21 @@ public class UserTestData { public static final String ADMIN_MAIL = "admin@gmail.com"; public static final String GUEST_MAIL = "guest@gmail.com"; - public static final User user = new User(USER_ID, "User", USER_MAIL, "password", Role.USER); - public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); - public static final User guest = new User(GUEST_ID, "Guest", GUEST_MAIL, "guest"); + public static final User user = new User(USER_ID, "User", USER_MAIL, "password", 2005, Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", 1900, Role.ADMIN, Role.USER); + public static final User guest = new User(GUEST_ID, "Guest", GUEST_MAIL, "guest", 2000); + + static { + user.setMeals(meals); + admin.setMeals(List.of(adminMeal2, adminMeal1)); + } public static User getNew() { - return new User(null, "New", "new@gmail.com", "newPass", false, new Date(), Collections.singleton(Role.USER)); + return new User(null, "New", "new@gmail.com", "newPass", 1555, false, new Date(), Collections.singleton(Role.USER)); } public static User getUpdated() { - return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), List.of(Role.ADMIN)); + return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", 330, false, new Date(), List.of(Role.ADMIN)); } public static String jsonWithPassword(User user, String passw) { diff --git a/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java index 98879c489..e4beacc98 100644 --- a/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java +++ b/src/test/java/ru/javaops/topjava/user/web/AdminUserControllerTest.java @@ -147,10 +147,20 @@ void enable() throws Exception { assertFalse(repository.getExisted(USER_ID).isEnabled()); } + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getWithMeals() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + ADMIN_ID + "/with-meals")) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(admin)); + } + @Test @WithUserDetails(value = ADMIN_MAIL) void createInvalid() throws Exception { - User invalid = new User(null, null, "", "newPass", Role.USER, Role.ADMIN); + User invalid = new User(null, null, "", "newPass", 7300, Role.USER, Role.ADMIN); perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(invalid, "newPass"))) @@ -198,7 +208,7 @@ void updateDuplicate() throws Exception { @Test @WithUserDetails(value = ADMIN_MAIL) void createDuplicate() throws Exception { - User expected = new User(null, "New", USER_MAIL, "newPass", Role.USER, Role.ADMIN); + User expected = new User(null, "New", USER_MAIL, "newPass", 2300, Role.USER, Role.ADMIN); perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(jsonWithPassword(expected, "newPass"))) diff --git a/src/test/java/ru/javaops/topjava/user/web/MealControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/MealControllerTest.java new file mode 100644 index 000000000..dd1c49f75 --- /dev/null +++ b/src/test/java/ru/javaops/topjava/user/web/MealControllerTest.java @@ -0,0 +1,188 @@ +package ru.javaops.topjava.user.web; + + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.topjava.AbstractControllerTest; +import ru.javaops.topjava.common.util.JsonUtil; +import ru.javaops.topjava.user.UserTestData; +import ru.javaops.topjava.user.model.Meal; +import ru.javaops.topjava.user.repository.MealRepository; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static ru.javaops.topjava.user.MealTestData.*; +import static ru.javaops.topjava.user.UserTestData.ADMIN_MAIL; +import static ru.javaops.topjava.user.UserTestData.USER_MAIL; +import static ru.javaops.topjava.user.util.MealsUtil.createTo; +import static ru.javaops.topjava.user.util.MealsUtil.getTos; +import static ru.javaops.topjava.user.web.MealController.REST_URL; + +class MealControllerTest extends AbstractControllerTest { + + private static final String REST_URL_SLASH = REST_URL + '/'; + + @Autowired + private MealRepository mealRepository; + + @Test + @WithUserDetails(value = USER_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + MEAL1_ID)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(MEAL_MATCHER.contentJson(meal1)); + } + + @Test + void getUnauth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + MEAL1_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + ADMIN_MEAL_ID)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + MEAL1_ID)) + .andExpect(status().isNoContent()); + assertFalse(mealRepository.get(UserTestData.USER_ID, MEAL1_ID).isPresent()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void deleteDataConflict() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + ADMIN_MEAL_ID)) + .andExpect(status().isConflict()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update() throws Exception { + Meal updated = getUpdated(); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updated))) + .andExpect(status().isNoContent()); + + MEAL_MATCHER.assertMatch(mealRepository.getExisted(MEAL1_ID), updated); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void createWithLocation() throws Exception { + Meal newMeal = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newMeal))); + + Meal created = MEAL_MATCHER.readFromJson(action); + int newId = created.id(); + newMeal.setId(newId); + MEAL_MATCHER.assertMatch(created, newMeal); + MEAL_MATCHER.assertMatch(mealRepository.getExisted(newId), newMeal); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(MEAL_TO_MATCHER.contentJson(getTos(meals, UserTestData.user.getCaloriesPerDay()))); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getBetween() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + "filter") + .param("startDate", "2020-01-30").param("startTime", "07:00") + .param("endDate", "2020-01-31").param("endTime", "11:00")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(MEAL_TO_MATCHER.contentJson(createTo(meal5, true), createTo(meal1, false))); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getBetweenAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + "filter?startDate=&endTime=")) + .andExpect(status().isOk()) + .andExpect(MEAL_TO_MATCHER.contentJson(getTos(meals, UserTestData.user.getCaloriesPerDay()))); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createInvalid() throws Exception { + Meal invalid = new Meal(null, null, "Dummy", 200); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateInvalid() throws Exception { + Meal invalid = new Meal(MEAL1_ID, null, null, 6000); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateHtmlUnsafe() throws Exception { + Meal invalid = new Meal(MEAL1_ID, LocalDateTime.now(), "", 200); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @Transactional(propagation = Propagation.NEVER) + @WithUserDetails(value = USER_MAIL) + void updateDuplicate() throws Exception { + Meal invalid = new Meal(MEAL1_ID, meal2.getDateTime(), "Dummy", 200); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + MEAL1_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid))) + .andDo(print()) + .andExpect(status().isConflict()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createDuplicate() throws Exception { + Meal invalid = new Meal(null, adminMeal1.getDateTime(), "Dummy", 200); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(invalid))) + .andDo(print()) + .andExpect(status().isConflict()); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java index 4ea8bbd6c..a105bd70c 100644 --- a/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java +++ b/src/test/java/ru/javaops/topjava/user/web/ProfileControllerTest.java @@ -48,9 +48,19 @@ void delete() throws Exception { USER_MATCHER.assertMatch(repository.findAll(), admin, guest); } + @Test + @WithUserDetails(value = USER_MAIL) + void getWithMeals() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL + "/with-meals")) + .andExpect(status().isOk()) + .andDo(print()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_WITH_MEALS_MATCHER.contentJson(user)); + } + @Test void register() throws Exception { - UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword"); + UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword", 1500); User newUser = UsersUtil.createNewFromTo(newTo); ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) @@ -68,7 +78,7 @@ void register() throws Exception { @Test @WithUserDetails(value = USER_MAIL) void update() throws Exception { - UserTo updatedTo = new UserTo(null, "newName", USER_MAIL, "newPassword"); + UserTo updatedTo = new UserTo(null, "newName", USER_MAIL, "newPassword", 1500); perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print()) @@ -79,7 +89,7 @@ void update() throws Exception { @Test void registerInvalid() throws Exception { - UserTo newTo = new UserTo(null, null, null, null); + UserTo newTo = new UserTo(null, null, null, null, 1); perform(MockMvcRequestBuilders.post(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(newTo))) @@ -90,7 +100,7 @@ void registerInvalid() throws Exception { @Test @WithUserDetails(value = USER_MAIL) void updateInvalid() throws Exception { - UserTo updatedTo = new UserTo(null, null, "password", null); + UserTo updatedTo = new UserTo(null, null, "password", null, 1); perform(MockMvcRequestBuilders.put(REST_URL) .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) @@ -101,7 +111,7 @@ void updateInvalid() throws Exception { @Test @WithUserDetails(value = USER_MAIL) void updateDuplicate() throws Exception { - UserTo updatedTo = new UserTo(null, "newName", ADMIN_MAIL, "newPassword"); + UserTo updatedTo = new UserTo(null, "newName", ADMIN_MAIL, "newPassword", 1500); perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.writeValue(updatedTo))) .andDo(print())