diff --git a/.docker/.dockerignore b/.docker/.dockerignore new file mode 100644 index 0000000..0159db9 --- /dev/null +++ b/.docker/.dockerignore @@ -0,0 +1,13 @@ +*.bat +*.dockerignore +*.editorconfig +*.gitattributes +*.gitignore +*.iml +*.md +*.yml +.git/ +.github/ +.idea/ +.vscode/ +target/ diff --git a/.docker/docker-compose.yaml b/.docker/docker-compose.yaml new file mode 100644 index 0000000..c2ec3bc --- /dev/null +++ b/.docker/docker-compose.yaml @@ -0,0 +1,143 @@ +# docker compose up --detach --build --force-recreate --remove-orphans + +name: java +services: + application: + image: application + container_name: application + depends_on: + - elk-elasticsearch + - elk-kibana + - kafka + - localstack + - mongo + - postgres + build: + context: .. + dockerfile: .docker/dockerfile + ports: + - "8090:8080" + environment: + SPRING_PROFILES_ACTIVE: local + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/database + SPRING_DATASOURCE_USERNAME: admin + SPRING_DATASOURCE_PASSWORD: password + SPRING_DATA_MONGODB_URI: mongodb://admin:password@mongo:27017/database?authSource=admin + SPRING_KAFKA: kafka:9094 + AWS_ENDPOINT: http://localstack:4566 + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + SPRING_CLOUD_AWS_S3_BUCKET: bucket + SPRING_CLOUD_AWS_SQS_QUEUE: queue + elk-elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.15.2 + container_name: elk-elasticsearch + ports: + - "9200:9200" + - "9300:9300" + volumes: + - elk-elasticsearch:/usr/share/elasticsearch/data + environment: + discovery.type: single-node + xpack.security.enabled: false + xpack.security.enrollment.enabled: false + ES_JAVA_OPTS: -Xms512m -Xmx512m + elk-kibana: + image: docker.elastic.co/kibana/kibana:8.15.2 + container_name: elk-kibana + depends_on: + - elk-elasticsearch + ports: + - "5601:5601" + environment: + ELASTICSEARCH_URL: http://elk-elasticsearch:9200 + ELASTICSEARCH_HOSTS: http://elk-elasticsearch:9200 + kafka: + image: bitnami/kafka + container_name: kafka + ports: + - "9092:9092" + - "9094:9094" + environment: + KAFKA_KRAFT_CLUSTER_ID: 0 + KAFKA_CFG_NODE_ID: 0 + KAFKA_CFG_PROCESS_ROLES: controller,broker + KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: true + KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 + KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CFG_LISTENERS: CONTROLLER://:9093,PLAINTEXT://:9094,EXTERNAL://:9092 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9094,EXTERNAL://localhost:9092 + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT + kafka-admin: + image: obsidiandynamics/kafdrop + container_name: kafka-admin + depends_on: + - kafka + ports: + - "9000:9000" + environment: + KAFKA_BROKERCONNECT: kafka:9094 + localstack: + image: localstack/localstack + container_name: localstack + ports: + - "4566:4566" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./localstack.sh:/etc/localstack/init/ready.d/localstack.sh + environment: + - SERVICES=sqs,sqs-query,s3 + mongo: + image: mongo + container_name: mongo + ports: + - "27017:27017" + volumes: + - mongo:/data/db + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: password + mongo-admin: + image: mongo-express + container_name: mongo-admin + depends_on: + - mongo + ports: + - "27018:8081" + environment: + ME_CONFIG_MONGODB_URL: mongodb://admin:password@mongo:27017 + ME_CONFIG_MONGODB_ADMINUSERNAME: admin + ME_CONFIG_MONGODB_ADMINPASSWORD: password + ME_CONFIG_BASICAUTH: false + postgres: + image: postgres + container_name: postgres + ports: + - "5432:5432" + volumes: + - postgres:/var/lib/postgresql/data + environment: + POSTGRES_DB: database + POSTGRES_USER: admin + POSTGRES_PASSWORD: password + postgres-admin: + image: dpage/pgadmin4 + container_name: postgres-admin + depends_on: + - postgres + ports: + - "5433:80" + volumes: + - ./servers.json:/pgadmin4/servers.json + - postgres-admin:/var/lib/pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: password + PGADMIN_CONFIG_SERVER_MODE: "False" + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" +volumes: + elk-elasticsearch: + mongo: + postgres: + postgres-admin: diff --git a/.docker/dockerfile b/.docker/dockerfile new file mode 100644 index 0000000..04b72ca --- /dev/null +++ b/.docker/dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:23-jdk-alpine AS build +RUN apk add --no-cache maven +WORKDIR /source +COPY source/pom.xml . +RUN mvn dependency:go-offline +COPY source . +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:23-jre-alpine +WORKDIR /app +COPY --from=build /source/target/*.jar app.jar +EXPOSE 8090 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/.docker/localstack.sh b/.docker/localstack.sh new file mode 100644 index 0000000..45717fe --- /dev/null +++ b/.docker/localstack.sh @@ -0,0 +1,4 @@ +#!/bin/bash +localstack status services +awslocal sqs create-queue --queue-name queue +awslocal s3 mb s3://bucket diff --git a/.docker/servers.json b/.docker/servers.json new file mode 100644 index 0000000..ed9fecc --- /dev/null +++ b/.docker/servers.json @@ -0,0 +1,15 @@ +{ + "Servers": { + "Database": { + "Group": "Servers", + "Name": "Docker", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "postgres", + "Username": "admin", + "Password": "password", + "SSLMode": "prefer", + "Favorite": true + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..54a09f4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 500 +tab_width = 4 +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1c7c280 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.java diff=java diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..a62efac --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,26 @@ +name: build +on: + push: + branches: [main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Java Setup + uses: actions/setup-java@v4 + with: + java-version: 23 + distribution: temurin + cache: maven + + - name: Java Publish + run: mvn -B clean package --file source/pom.xml + + - name: Artifact Upload + uses: actions/upload-artifact@v4 + with: + name: app + path: source/target/*.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f919350 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.bat +*.iml +.idea +.vscode +target \ No newline at end of file diff --git a/license.md b/license.md new file mode 100644 index 0000000..1f95d26 --- /dev/null +++ b/license.md @@ -0,0 +1,5 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b0f6d31 --- /dev/null +++ b/readme.md @@ -0,0 +1,69 @@ +# JAVA + +![](https://github.com/rafaelfgx/Java/actions/workflows/build.yaml/badge.svg) + +API using Java, Spring Boot, Docker, Testcontainers, PostgreSQL, MongoDB, Kafka, LocalStack, SQS, S3, JWT, Swagger. + +## TECHNOLOGIES + +* [Java](https://dev.java) +* [Spring Boot](https://spring.io/projects/spring-boot) +* [Docker](https://www.docker.com/get-started) +* [Testcontainers](https://testcontainers.com) +* [PostgreSQL](https://www.postgresql.org/) +* [MongoDB](https://www.mongodb.com/docs/manual) +* [Kafka](https://kafka.apache.org) +* [LocalStack](https://localstack.cloud) +* [AWS SQS](https://aws.amazon.com/sqs) +* [AWS S3](https://aws.amazon.com/s3) +* [JWT](https://jwt.io) +* [Swagger](https://swagger.io) + +## RUN + +
+IntelliJ IDEA + +#### Prerequisites + +* [Docker](https://www.docker.com/get-started) +* [Java JDK](https://www.oracle.com/java/technologies/downloads) +* [IntelliJ IDEA](https://www.jetbrains.com/idea/download) + +#### Steps + +1. Execute **docker compose up --detach --build --force-recreate --remove-orphans** in **docker** directory. +2. Open **source** directory in **IntelliJ IDEA**. +3. Select **Application.java** class. +4. Click **Run** or **Debug**. +5. Open . + +
+ +
+Docker + +#### Prerequisites + +* [Docker](https://www.docker.com/get-started) + +#### Steps + +1. Execute **docker compose up --detach --build --force-recreate --remove-orphans** in **docker** directory. +2. Open . + +
+ +## EXAMPLES + +* **AWS:** Amazon Web Services [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/aws) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/aws) +* **Auth:** Authentication and Authorization [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/auth) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/auth) +* **Category:** Cache [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/category) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/category) +* **Game:** Mocks [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/game) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/game) +* **Group:** Groups [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/group) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/group) +* **Invoice:** PostgreSQL [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/invoice) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/invoice) +* **Location:** Flat Object to Nested Object [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/location) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/location) +* **Notification:** Kafka [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/notification) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/notification) +* **Payment:** Strategy Pattern [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/payment) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/payment) +* **Product:** MongoDB [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/product) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/product) +* **User:** Business Rules [Main](https://github.com/rafaelfgx/Java/tree/main/source/src/main/java/com/company/architecture/user) | [Tests](https://github.com/rafaelfgx/Java/tree/main/source/src/test/java/com/company/architecture/user) diff --git a/source/.run/Application.run.xml b/source/.run/Application.run.xml new file mode 100644 index 0000000..2e0cb46 --- /dev/null +++ b/source/.run/Application.run.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/source/.run/Tests.run.xml b/source/.run/Tests.run.xml new file mode 100644 index 0000000..bf0684e --- /dev/null +++ b/source/.run/Tests.run.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/source/lombok.config b/source/lombok.config new file mode 100644 index 0000000..7a21e88 --- /dev/null +++ b/source/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/source/pom.xml b/source/pom.xml new file mode 100644 index 0000000..58d42e1 --- /dev/null +++ b/source/pom.xml @@ -0,0 +1,133 @@ + + + 4.0.0 + com.company + architecture + architecture + 1.0.0 + + 23 + full + UTF-8 + UTF-8 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.springframework.kafka + spring-kafka + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + org.projectlombok + lombok + provided + 1.18.34 + + + org.postgresql + postgresql + 42.7.4 + + + io.awspring.cloud + spring-cloud-aws-starter-s3 + 3.2.1 + + + io.awspring.cloud + spring-cloud-aws-starter-sqs + 3.2.1 + + + com.auth0 + java-jwt + 4.4.0 + + + com.auth0 + jwks-rsa + 0.22.1 + + + org.testcontainers + junit-jupiter + test + 1.20.3 + + + org.testcontainers + postgresql + test + 1.20.3 + + + org.testcontainers + mongodb + test + 1.20.3 + + + org.testcontainers + localstack + test + 1.20.3 + + + org.testcontainers + kafka + test + 1.20.3 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/source/src/main/java/com/company/architecture/Application.java b/source/src/main/java/com/company/architecture/Application.java new file mode 100644 index 0000000..ac7f2f7 --- /dev/null +++ b/source/src/main/java/com/company/architecture/Application.java @@ -0,0 +1,15 @@ +package com.company.architecture; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableCaching +@EnableScheduling +@SpringBootApplication +public class Application { + public static void main(final String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/source/src/main/java/com/company/architecture/auth/AuthConfiguration.java b/source/src/main/java/com/company/architecture/auth/AuthConfiguration.java new file mode 100644 index 0000000..7d83b2a --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/AuthConfiguration.java @@ -0,0 +1,43 @@ +package com.company.architecture.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class AuthConfiguration { + private final AuthFilter authFilter; + + @Bean + AuthenticationManager authenticationManager(final AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + SecurityFilterChain securityFilterChain(final HttpSecurity httpSecurity) throws Exception { + return httpSecurity + .csrf(AbstractHttpConfigurer::disable) + .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(registry -> registry + .requestMatchers("/", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/auth").permitAll() + .requestMatchers(HttpMethod.DELETE).hasAuthority(Authority.ADMINISTRATOR.name()) + .anyRequest().authenticated() + ) + .build(); + } +} diff --git a/source/src/main/java/com/company/architecture/auth/AuthController.java b/source/src/main/java/com/company/architecture/auth/AuthController.java new file mode 100644 index 0000000..d6fd4b0 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/AuthController.java @@ -0,0 +1,25 @@ +package com.company.architecture.auth; + +import com.company.architecture.shared.swagger.PostApiResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Auth") +@RequiredArgsConstructor +@RestController +@RequestMapping("/auth") +public class AuthController { + private final AuthService authService; + + @Operation(summary = "Auth") + @PostApiResponses + @PostMapping + @ResponseStatus(HttpStatus.OK) + public String auth(@RequestBody @Valid final AuthDto dto) { + return authService.auth(dto); + } +} diff --git a/source/src/main/java/com/company/architecture/auth/AuthDto.java b/source/src/main/java/com/company/architecture/auth/AuthDto.java new file mode 100644 index 0000000..10b9453 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/AuthDto.java @@ -0,0 +1,6 @@ +package com.company.architecture.auth; + +import jakarta.validation.constraints.NotBlank; + +public record AuthDto(@NotBlank String username, @NotBlank String password) { +} diff --git a/source/src/main/java/com/company/architecture/auth/AuthFilter.java b/source/src/main/java/com/company/architecture/auth/AuthFilter.java new file mode 100644 index 0000000..2a13a41 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/AuthFilter.java @@ -0,0 +1,33 @@ +package com.company.architecture.auth; + +import jakarta.annotation.Nonnull; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class AuthFilter extends OncePerRequestFilter { + private final JwtService jwtService; + + @Override + protected void doFilterInternal(final HttpServletRequest request, final @Nonnull HttpServletResponse response, final @Nonnull FilterChain filterChain) throws ServletException, IOException { + final var jwt = StringUtils.removeStart(StringUtils.defaultString(request.getHeader(HttpHeaders.AUTHORIZATION)), "Bearer").trim(); + + if (jwtService.verify(jwt)) { + SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(jwtService.getSubject(jwt), null, jwtService.getAuthorities(jwt))); + } + + filterChain.doFilter(request, response); + } +} diff --git a/source/src/main/java/com/company/architecture/auth/AuthService.java b/source/src/main/java/com/company/architecture/auth/AuthService.java new file mode 100644 index 0000000..abd3c61 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/AuthService.java @@ -0,0 +1,24 @@ +package com.company.architecture.auth; + +import com.company.architecture.shared.exception.ApplicationException; +import com.company.architecture.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthService { + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final UserRepository userRepository; + + public String auth(final AuthDto dto) { + return userRepository + .findByUsername(dto.username()) + .filter(user -> passwordEncoder.matches(dto.password(), user.getPassword())) + .map(jwtService::create) + .orElseThrow(() -> new ApplicationException(HttpStatus.UNAUTHORIZED, "auth.unauthorized")); + } +} diff --git a/source/src/main/java/com/company/architecture/auth/Authority.java b/source/src/main/java/com/company/architecture/auth/Authority.java new file mode 100644 index 0000000..1dc34b5 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/Authority.java @@ -0,0 +1,6 @@ +package com.company.architecture.auth; + +public enum Authority { + DEFAULT, + ADMINISTRATOR +} diff --git a/source/src/main/java/com/company/architecture/auth/JwtService.java b/source/src/main/java/com/company/architecture/auth/JwtService.java new file mode 100644 index 0000000..7857245 --- /dev/null +++ b/source/src/main/java/com/company/architecture/auth/JwtService.java @@ -0,0 +1,61 @@ +package com.company.architecture.auth; + +import com.auth0.jwk.JwkProviderBuilder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.company.architecture.user.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.stereotype.Service; + +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.List; + +@Service +public class JwtService { + private final Algorithm algorithm; + + public JwtService() throws NoSuchAlgorithmException { + final var keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + algorithm = Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), (RSAPrivateKey) keyPair.getPrivate()); + } + + public String create(final User user) { + final var authorities = user.getAuthorities().stream().map(Enum::name).toArray(String[]::new); + return JWT.create().withSubject(user.getId().toString()).withArrayClaim("authorities", authorities).sign(algorithm); + } + + public boolean verify(final String jwt) { + try { + algorithm.verify(JWT.decode(jwt)); + return true; + } catch (Exception exception) { + return false; + } + } + + public boolean verifyInProvider(final String jwt) { + try { + final var decoded = JWT.decode(jwt); + final var provider = new JwkProviderBuilder("DOMAIN").build(); + final var jwk = provider.get(decoded.getKeyId()); + final var key = (RSAKey) jwk.getPublicKey(); + Algorithm.RSA256(key).verify(decoded); + return true; + } catch (Exception exception) { + return false; + } + } + + public String getSubject(final String jwt) { + return JWT.decode(jwt).getSubject(); + } + + public List getAuthorities(final String jwt) { + return AuthorityUtils.createAuthorityList(JWT.decode(jwt).getClaim("authorities").asList(String.class)); + } +} diff --git a/source/src/main/java/com/company/architecture/aws/AwsConfiguration.java b/source/src/main/java/com/company/architecture/aws/AwsConfiguration.java new file mode 100644 index 0000000..9c1a24a --- /dev/null +++ b/source/src/main/java/com/company/architecture/aws/AwsConfiguration.java @@ -0,0 +1,36 @@ +package com.company.architecture.aws; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +import java.net.URI; +import java.util.Objects; + +@RequiredArgsConstructor +@Configuration +public class AwsConfiguration { + private final Environment environment; + + @Bean + public SqsAsyncClient sqsAsyncClient() { + final var client = SqsAsyncClient.builder().endpointOverride(endpoint()).build(); + client.createQueue(builder -> builder.queueName(environment.getProperty("spring.cloud.aws.sqs.queue"))); + return client; + } + + @Bean + public S3Client s3Client() { + final var client = S3Client.builder().endpointOverride(endpoint()).serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()).build(); + client.createBucket(builder -> builder.bucket(environment.getProperty("spring.cloud.aws.s3.bucket"))); + return client; + } + + private URI endpoint() { + return URI.create(Objects.requireNonNullElse(environment.getProperty("AWS_ENDPOINT"), environment.getProperty("aws.endpoint"))); + } +} diff --git a/source/src/main/java/com/company/architecture/aws/AwsController.java b/source/src/main/java/com/company/architecture/aws/AwsController.java new file mode 100644 index 0000000..d0bfffc --- /dev/null +++ b/source/src/main/java/com/company/architecture/aws/AwsController.java @@ -0,0 +1,46 @@ +package com.company.architecture.aws; + +import com.company.architecture.shared.swagger.GetApiResponses; +import com.company.architecture.shared.swagger.PostApiResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Tag(name = "AWS") +@RequiredArgsConstructor +@RestController +@RequestMapping("/aws") +public class AwsController { + private final AwsService awsService; + + @Operation(summary = "Send") + @PostApiResponses + @PostMapping("queues/send") + public void send(@RequestBody final String message) { + awsService.send(message); + } + + @Operation(summary = "Upload") + @PostApiResponses + @PostMapping(value = "files/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) + public String upload(@RequestParam MultipartFile file) throws IOException { + return awsService.upload(file).getFilename(); + } + + @Operation(summary = "Download") + @GetApiResponses + @GetMapping("files/download/{key}") + public ResponseEntity get(@PathVariable final String key) { + final var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + return ResponseEntity.ok().headers(headers).body(awsService.download(key)); + } +} diff --git a/source/src/main/java/com/company/architecture/aws/AwsS3Service.java b/source/src/main/java/com/company/architecture/aws/AwsS3Service.java new file mode 100644 index 0000000..0bbc7a4 --- /dev/null +++ b/source/src/main/java/com/company/architecture/aws/AwsS3Service.java @@ -0,0 +1,39 @@ +package com.company.architecture.aws; + +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +@Service +@RequiredArgsConstructor +public class AwsS3Service { + private final S3Template s3Template; + + public S3Resource store(final String bucket, final String key, final Object object) { + return s3Template.store(bucket, key, object); + } + + public T read(final String bucket, final String key, final Class clazz) { + return s3Template.read(bucket, key, clazz); + } + + public S3Resource upload(final String bucket, final MultipartFile file) throws IOException { + return upload(bucket, file.getOriginalFilename(), file.getBytes()); + } + + public S3Resource upload(final String bucket, final String key, final byte[] bytes) throws IOException { + return s3Template.upload(bucket, key, new ByteArrayInputStream(bytes), ObjectMetadata.builder().contentType(Files.probeContentType(Paths.get(key))).build()); + } + + public S3Resource download(final String bucket, final String key) { + return s3Template.download(bucket, key); + } +} diff --git a/source/src/main/java/com/company/architecture/aws/AwsService.java b/source/src/main/java/com/company/architecture/aws/AwsService.java new file mode 100644 index 0000000..8f01e61 --- /dev/null +++ b/source/src/main/java/com/company/architecture/aws/AwsService.java @@ -0,0 +1,42 @@ +package com.company.architecture.aws; + +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.sqs.annotation.SqsListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AwsService { + private final AwsSqsService awsSqsService; + private final AwsS3Service awsS3Service; + + @Value("${spring.cloud.aws.sqs.queue}") + private String queue; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + @SqsListener("${spring.cloud.aws.sqs.queue}") + public void listen(final Object object) { + log.info("[AwsSqsService.listen]: {}", object); + } + + public void send(final Object object) { + awsSqsService.send(queue, object); + } + + public S3Resource upload(final MultipartFile file) throws IOException { + return awsS3Service.upload(bucket, file.getOriginalFilename(), file.getBytes()); + } + + public S3Resource download(final String key) { + return awsS3Service.download(bucket, key); + } +} diff --git a/source/src/main/java/com/company/architecture/aws/AwsSqsService.java b/source/src/main/java/com/company/architecture/aws/AwsSqsService.java new file mode 100644 index 0000000..1de1147 --- /dev/null +++ b/source/src/main/java/com/company/architecture/aws/AwsSqsService.java @@ -0,0 +1,17 @@ +package com.company.architecture.aws; + +import io.awspring.cloud.sqs.operations.SqsTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AwsSqsService { + private final SqsTemplate sqsTemplate; + + public void send(final String queue, final Object object) { + sqsTemplate.send(queue, object); + } +} diff --git a/source/src/main/java/com/company/architecture/category/Category.java b/source/src/main/java/com/company/architecture/category/Category.java new file mode 100644 index 0000000..a35caba --- /dev/null +++ b/source/src/main/java/com/company/architecture/category/Category.java @@ -0,0 +1,6 @@ +package com.company.architecture.category; + +import jakarta.validation.constraints.NotBlank; + +public record Category(@NotBlank String name) { +} diff --git a/source/src/main/java/com/company/architecture/category/CategoryCacheService.java b/source/src/main/java/com/company/architecture/category/CategoryCacheService.java new file mode 100644 index 0000000..d029403 --- /dev/null +++ b/source/src/main/java/com/company/architecture/category/CategoryCacheService.java @@ -0,0 +1,31 @@ +package com.company.architecture.category; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CategoryCacheService { + private static final String KEY = "Category"; + private final CategoryRepository categoryRepository; + + @Cacheable(KEY) + public List list() { + log.info("Cache -> Loading: {}", KEY); + return categoryRepository.list(); + } + + @CacheEvict(allEntries = true, cacheNames = {KEY}) + @Scheduled(fixedRateString = "1", timeUnit = TimeUnit.HOURS) + public void cacheEvict() { + log.info("Cache -> Cleaning: {}", KEY); + } +} diff --git a/source/src/main/java/com/company/architecture/category/CategoryRepository.java b/source/src/main/java/com/company/architecture/category/CategoryRepository.java new file mode 100644 index 0000000..756826d --- /dev/null +++ b/source/src/main/java/com/company/architecture/category/CategoryRepository.java @@ -0,0 +1,12 @@ +package com.company.architecture.category; + +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class CategoryRepository { + public List list() { + return List.of(new Category("Category")); + } +} diff --git a/source/src/main/java/com/company/architecture/category/CategoryService.java b/source/src/main/java/com/company/architecture/category/CategoryService.java new file mode 100644 index 0000000..43f4961 --- /dev/null +++ b/source/src/main/java/com/company/architecture/category/CategoryService.java @@ -0,0 +1,16 @@ +package com.company.architecture.category; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CategoryService { + private final CategoryCacheService categoryCacheService; + + public List list() { + return categoryCacheService.list(); + } +} diff --git a/source/src/main/java/com/company/architecture/game/Game.java b/source/src/main/java/com/company/architecture/game/Game.java new file mode 100644 index 0000000..1f6a708 --- /dev/null +++ b/source/src/main/java/com/company/architecture/game/Game.java @@ -0,0 +1,6 @@ +package com.company.architecture.game; + +import jakarta.validation.constraints.NotBlank; + +public record Game(@NotBlank String title) { +} diff --git a/source/src/main/java/com/company/architecture/game/GameRepository.java b/source/src/main/java/com/company/architecture/game/GameRepository.java new file mode 100644 index 0000000..959b65f --- /dev/null +++ b/source/src/main/java/com/company/architecture/game/GameRepository.java @@ -0,0 +1,14 @@ +package com.company.architecture.game; + +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class GameRepository { + private static final List games = List.of(new Game("Game A"), new Game("Game B")); + + public List list(final Game game) { + return games.stream().filter(item -> item.title().contains(game.title())).toList(); + } +} diff --git a/source/src/main/java/com/company/architecture/game/GameService.java b/source/src/main/java/com/company/architecture/game/GameService.java new file mode 100644 index 0000000..16d7880 --- /dev/null +++ b/source/src/main/java/com/company/architecture/game/GameService.java @@ -0,0 +1,16 @@ +package com.company.architecture.game; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GameService { + private final GameRepository gameRepository; + + public List list(final Game game) { + return gameRepository.list(game); + } +} diff --git a/source/src/main/java/com/company/architecture/group/GroupService.java b/source/src/main/java/com/company/architecture/group/GroupService.java new file mode 100644 index 0000000..ab7409a --- /dev/null +++ b/source/src/main/java/com/company/architecture/group/GroupService.java @@ -0,0 +1,36 @@ +package com.company.architecture.group; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanWrapperImpl; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class GroupService { + public Map> groupPropertiesBy(final Object object, final String contains) { + final var result = new HashMap>(); + + Arrays.stream(object.getClass().getDeclaredFields()).filter(field -> field.getName().contains(contains)).forEach(field -> { + final var wrapper = new BeanWrapperImpl(object); + final var name = field.getName().replace(contains, ""); + result.computeIfAbsent(wrapper.getPropertyValue(field.getName()), mapping -> new HashMap<>()).put(name, wrapper.getPropertyValue(name)); + }); + + return result; + } + + public void setProperties(final Object object, final Map map) { + final var wrapper = new BeanWrapperImpl(object); + + map.forEach((property, value) -> { + if (Objects.nonNull(value)) { + wrapper.setPropertyValue(property, value); + } + }); + } +} diff --git a/source/src/main/java/com/company/architecture/group/Person.java b/source/src/main/java/com/company/architecture/group/Person.java new file mode 100644 index 0000000..d2b9ebe --- /dev/null +++ b/source/src/main/java/com/company/architecture/group/Person.java @@ -0,0 +1,13 @@ +package com.company.architecture.group; + +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class Person { + private String passport; + private LocalDate passportExpirationDate; + private String driverLicense; + private LocalDate driverLicenseExpirationDate; +} diff --git a/source/src/main/java/com/company/architecture/invoice/InvoiceController.java b/source/src/main/java/com/company/architecture/invoice/InvoiceController.java new file mode 100644 index 0000000..d7c3a00 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/InvoiceController.java @@ -0,0 +1,35 @@ +package com.company.architecture.invoice; + +import com.company.architecture.invoice.dtos.AddInvoiceDto; +import com.company.architecture.invoice.dtos.InvoiceDto; +import com.company.architecture.shared.swagger.GetApiResponses; +import com.company.architecture.shared.swagger.PostApiResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Invoices") +@RequiredArgsConstructor +@RestController +@RequestMapping("/invoices") +public class InvoiceController { + private final InvoiceService invoiceService; + + @Operation(summary = "Get") + @GetApiResponses + @GetMapping + public List get() { + return invoiceService.get(); + } + + @Operation(summary = "Add") + @PostApiResponses + @PostMapping + public InvoiceDto add(@RequestBody @Valid final AddInvoiceDto dto) { + return invoiceService.add(dto); + } +} diff --git a/source/src/main/java/com/company/architecture/invoice/InvoiceRepository.java b/source/src/main/java/com/company/architecture/invoice/InvoiceRepository.java new file mode 100644 index 0000000..9c399ac --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/InvoiceRepository.java @@ -0,0 +1,9 @@ +package com.company.architecture.invoice; + +import com.company.architecture.invoice.entities.Invoice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface InvoiceRepository extends JpaRepository { +} diff --git a/source/src/main/java/com/company/architecture/invoice/InvoiceService.java b/source/src/main/java/com/company/architecture/invoice/InvoiceService.java new file mode 100644 index 0000000..834a700 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/InvoiceService.java @@ -0,0 +1,27 @@ +package com.company.architecture.invoice; + +import com.company.architecture.invoice.dtos.AddInvoiceDto; +import com.company.architecture.invoice.dtos.InvoiceDto; +import com.company.architecture.invoice.entities.Invoice; +import com.company.architecture.shared.services.MapperService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InvoiceService { + private final MapperService mapperService; + private final InvoiceRepository invoiceRepository; + + public List get() { + return mapperService.mapList(invoiceRepository.findAll(), InvoiceDto.class); + } + + @Transactional + public InvoiceDto add(final AddInvoiceDto dto) { + return mapperService.map(invoiceRepository.save(mapperService.map(dto, Invoice.class)), InvoiceDto.class); + } +} diff --git a/source/src/main/java/com/company/architecture/invoice/InvoiceStatus.java b/source/src/main/java/com/company/architecture/invoice/InvoiceStatus.java new file mode 100644 index 0000000..d47dbac --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/InvoiceStatus.java @@ -0,0 +1,5 @@ +package com.company.architecture.invoice; + +public enum InvoiceStatus { + DRAFT, ISSUED, PAID, CANCELED +} diff --git a/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceDto.java b/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceDto.java new file mode 100644 index 0000000..448f82f --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceDto.java @@ -0,0 +1,15 @@ +package com.company.architecture.invoice.dtos; + +import com.company.architecture.invoice.InvoiceStatus; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record AddInvoiceDto( + @NotNull @Size(min = 1, max = 50) String number, + @NotNull LocalDateTime dateTime, + @NotNull InvoiceStatus status, + List items) { +} diff --git a/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceItemDto.java b/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceItemDto.java new file mode 100644 index 0000000..0d8db8e --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/dtos/AddInvoiceItemDto.java @@ -0,0 +1,14 @@ +package com.company.architecture.invoice.dtos; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public record AddInvoiceItemDto( + @NotNull @Size(min = 1, max = 100) String product, + @NotNull @Min(value = 1) Integer quantity, + @NotNull @DecimalMin(value = "0.01") BigDecimal unitPrice) { +} diff --git a/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceDto.java b/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceDto.java new file mode 100644 index 0000000..3aec687 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceDto.java @@ -0,0 +1,14 @@ +package com.company.architecture.invoice.dtos; + +import com.company.architecture.invoice.InvoiceStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record InvoiceDto( + Long id, + String number, + LocalDateTime dateTime, + InvoiceStatus status, + List items) { +} diff --git a/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceItemDto.java b/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceItemDto.java new file mode 100644 index 0000000..bfbcf63 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/dtos/InvoiceItemDto.java @@ -0,0 +1,10 @@ +package com.company.architecture.invoice.dtos; + +import java.math.BigDecimal; + +public record InvoiceItemDto( + Long id, + String product, + Integer quantity, + BigDecimal unitPrice) { +} diff --git a/source/src/main/java/com/company/architecture/invoice/entities/BaseEntity.java b/source/src/main/java/com/company/architecture/invoice/entities/BaseEntity.java new file mode 100644 index 0000000..f97ab20 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/entities/BaseEntity.java @@ -0,0 +1,15 @@ +package com.company.architecture.invoice.entities; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Data; + +@MappedSuperclass +@Data +public abstract class BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; +} diff --git a/source/src/main/java/com/company/architecture/invoice/entities/Invoice.java b/source/src/main/java/com/company/architecture/invoice/entities/Invoice.java new file mode 100644 index 0000000..cc255c5 --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/entities/Invoice.java @@ -0,0 +1,37 @@ +package com.company.architecture.invoice.entities; + +import com.company.architecture.invoice.InvoiceStatus; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@Entity +@Table( + indexes = {@Index(columnList = "number")}, + uniqueConstraints = {@UniqueConstraint(columnNames = "number")} +) +public class Invoice extends BaseEntity { + @NotNull + @Size(min = 1, max = 50) + @EqualsAndHashCode.Include + private String number; + + @NotNull + private LocalDateTime dateTime; + + @NotNull + @Enumerated(EnumType.STRING) + private InvoiceStatus status; + + @OneToMany(cascade = CascadeType.ALL) + private List items; +} diff --git a/source/src/main/java/com/company/architecture/invoice/entities/InvoiceItem.java b/source/src/main/java/com/company/architecture/invoice/entities/InvoiceItem.java new file mode 100644 index 0000000..3ff0ffa --- /dev/null +++ b/source/src/main/java/com/company/architecture/invoice/entities/InvoiceItem.java @@ -0,0 +1,34 @@ +package com.company.architecture.invoice.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +import java.math.BigDecimal; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +@EqualsAndHashCode(callSuper = true, onlyExplicitlyIncluded = true) +@Entity +public class InvoiceItem extends BaseEntity { + @NotNull + @Size(min = 1, max = 100) + private String product; + + @NotNull + @Min(value = 1) + private BigDecimal quantity; + + @NotNull + @DecimalMin(value = "0.01") + private BigDecimal unitPrice; + + @ManyToOne + private Invoice invoice; +} diff --git a/source/src/main/java/com/company/architecture/location/City.java b/source/src/main/java/com/company/architecture/location/City.java new file mode 100644 index 0000000..9d1a013 --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/City.java @@ -0,0 +1,4 @@ +package com.company.architecture.location; + +public class City extends Location { +} diff --git a/source/src/main/java/com/company/architecture/location/Country.java b/source/src/main/java/com/company/architecture/location/Country.java new file mode 100644 index 0000000..b0956f4 --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/Country.java @@ -0,0 +1,13 @@ +package com.company.architecture.location; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class Country extends Location { + private List states = new ArrayList<>(); +} diff --git a/source/src/main/java/com/company/architecture/location/FlatLocation.java b/source/src/main/java/com/company/architecture/location/FlatLocation.java new file mode 100644 index 0000000..df8cc26 --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/FlatLocation.java @@ -0,0 +1,12 @@ +package com.company.architecture.location; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class FlatLocation { + private Location country; + private Location state; + private Location city; +} diff --git a/source/src/main/java/com/company/architecture/location/Location.java b/source/src/main/java/com/company/architecture/location/Location.java new file mode 100644 index 0000000..e270951 --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/Location.java @@ -0,0 +1,13 @@ +package com.company.architecture.location; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@Data +@NoArgsConstructor +class Location { + private String code; + private String name; +} diff --git a/source/src/main/java/com/company/architecture/location/LocationService.java b/source/src/main/java/com/company/architecture/location/LocationService.java new file mode 100644 index 0000000..b511e34 --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/LocationService.java @@ -0,0 +1,37 @@ +package com.company.architecture.location; + +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LocationService { + private static List getStates(final List locations, final Country country) { + return locations.stream().filter(location -> location.getCountry().getCode().equals(country.getCode())).map(FlatLocation::getState).distinct().map(state -> { + final var newState = new State(); + newState.setCode(state.getCode()); + newState.setName(state.getName()); + newState.setCities(getCities(locations, newState)); + return newState; + }).toList(); + } + + private static List getCities(final List locations, final State state) { + return locations.stream().filter(location -> location.getState().getCode().equals(state.getCode())).map(FlatLocation::getCity).distinct().map(city -> { + final var newCity = new City(); + newCity.setCode(city.getCode()); + newCity.setName(city.getName()); + return newCity; + }).toList(); + } + + public List getCountries(final List locations) { + return locations.stream().map(FlatLocation::getCountry).distinct().map(country -> { + final var newCountry = new Country(); + newCountry.setCode(country.getCode()); + newCountry.setName(country.getName()); + newCountry.setStates(getStates(locations, newCountry)); + return newCountry; + }).toList(); + } +} diff --git a/source/src/main/java/com/company/architecture/location/State.java b/source/src/main/java/com/company/architecture/location/State.java new file mode 100644 index 0000000..710032e --- /dev/null +++ b/source/src/main/java/com/company/architecture/location/State.java @@ -0,0 +1,13 @@ +package com.company.architecture.location; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class State extends Location { + private List cities = new ArrayList<>(); +} diff --git a/source/src/main/java/com/company/architecture/notification/Notification.java b/source/src/main/java/com/company/architecture/notification/Notification.java new file mode 100644 index 0000000..857cbb4 --- /dev/null +++ b/source/src/main/java/com/company/architecture/notification/Notification.java @@ -0,0 +1,6 @@ +package com.company.architecture.notification; + +import jakarta.validation.constraints.NotBlank; + +public record Notification(@NotBlank String message) { +} diff --git a/source/src/main/java/com/company/architecture/notification/NotificationConsumer.java b/source/src/main/java/com/company/architecture/notification/NotificationConsumer.java new file mode 100644 index 0000000..9f4ce53 --- /dev/null +++ b/source/src/main/java/com/company/architecture/notification/NotificationConsumer.java @@ -0,0 +1,14 @@ +package com.company.architecture.notification; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class NotificationConsumer { + @KafkaListener(topics = "notifications", groupId = "group") + public void listen(final Notification notification) { + log.info("[NotificationConsumer.listen]: {}", notification); + } +} diff --git a/source/src/main/java/com/company/architecture/notification/NotificationController.java b/source/src/main/java/com/company/architecture/notification/NotificationController.java new file mode 100644 index 0000000..deaa6cf --- /dev/null +++ b/source/src/main/java/com/company/architecture/notification/NotificationController.java @@ -0,0 +1,27 @@ +package com.company.architecture.notification; + +import com.company.architecture.shared.swagger.PostApiResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Notifications") +@RequiredArgsConstructor +@RestController +@RequestMapping("/notifications") +public class NotificationController { + private final NotificationProducer notificationProducer; + + @Operation(summary = "Produce") + @PostApiResponses + @PostMapping + @ResponseStatus(HttpStatus.OK) + public void produce() { + notificationProducer.produce(); + } +} diff --git a/source/src/main/java/com/company/architecture/notification/NotificationProducer.java b/source/src/main/java/com/company/architecture/notification/NotificationProducer.java new file mode 100644 index 0000000..b558a8e --- /dev/null +++ b/source/src/main/java/com/company/architecture/notification/NotificationProducer.java @@ -0,0 +1,17 @@ +package com.company.architecture.notification; + +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class NotificationProducer { + private final KafkaTemplate kafkaTemplate; + + public void produce() { + kafkaTemplate.send("notifications", new Notification(LocalDateTime.now().toString())); + } +} diff --git a/source/src/main/java/com/company/architecture/payment/CreditCardPaymentStrategy.java b/source/src/main/java/com/company/architecture/payment/CreditCardPaymentStrategy.java new file mode 100644 index 0000000..fad128a --- /dev/null +++ b/source/src/main/java/com/company/architecture/payment/CreditCardPaymentStrategy.java @@ -0,0 +1,11 @@ +package com.company.architecture.payment; + +import org.springframework.stereotype.Service; + +@Service +public class CreditCardPaymentStrategy implements PaymentStrategy { + @Override + public String pay() { + return CreditCardPaymentStrategy.class.getSimpleName(); + } +} diff --git a/source/src/main/java/com/company/architecture/payment/DebitCardPaymentStrategy.java b/source/src/main/java/com/company/architecture/payment/DebitCardPaymentStrategy.java new file mode 100644 index 0000000..2dc3425 --- /dev/null +++ b/source/src/main/java/com/company/architecture/payment/DebitCardPaymentStrategy.java @@ -0,0 +1,11 @@ +package com.company.architecture.payment; + +import org.springframework.stereotype.Service; + +@Service +public class DebitCardPaymentStrategy implements PaymentStrategy { + @Override + public String pay() { + return DebitCardPaymentStrategy.class.getSimpleName(); + } +} diff --git a/source/src/main/java/com/company/architecture/payment/PaymentMethod.java b/source/src/main/java/com/company/architecture/payment/PaymentMethod.java new file mode 100644 index 0000000..4a246a2 --- /dev/null +++ b/source/src/main/java/com/company/architecture/payment/PaymentMethod.java @@ -0,0 +1,12 @@ +package com.company.architecture.payment; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PaymentMethod { + CREDIT_CARD(CreditCardPaymentStrategy.class.getSimpleName()), + DEBIT_CARD(DebitCardPaymentStrategy.class.getSimpleName()); + private final String strategy; +} diff --git a/source/src/main/java/com/company/architecture/payment/PaymentService.java b/source/src/main/java/com/company/architecture/payment/PaymentService.java new file mode 100644 index 0000000..3bb7274 --- /dev/null +++ b/source/src/main/java/com/company/architecture/payment/PaymentService.java @@ -0,0 +1,19 @@ +package com.company.architecture.payment; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.TreeMap; + +@Service +public class PaymentService { + private final TreeMap strategies = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + public PaymentService(final Map strategies) { + this.strategies.putAll(strategies); + } + + public String process(final PaymentMethod paymentMethod) { + return strategies.get(paymentMethod.getStrategy()).pay(); + } +} diff --git a/source/src/main/java/com/company/architecture/payment/PaymentStrategy.java b/source/src/main/java/com/company/architecture/payment/PaymentStrategy.java new file mode 100644 index 0000000..cbeea51 --- /dev/null +++ b/source/src/main/java/com/company/architecture/payment/PaymentStrategy.java @@ -0,0 +1,5 @@ +package com.company.architecture.payment; + +public interface PaymentStrategy { + String pay(); +} diff --git a/source/src/main/java/com/company/architecture/product/Product.java b/source/src/main/java/com/company/architecture/product/Product.java new file mode 100644 index 0000000..cd3a7ab --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/Product.java @@ -0,0 +1,28 @@ +package com.company.architecture.product; + +import jakarta.persistence.Id; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.math.BigDecimal; +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +@Document +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class Product { + @Id + @EqualsAndHashCode.Include + private UUID id; + + @NotBlank + private String description; + + @Min(0L) + private BigDecimal price; +} diff --git a/source/src/main/java/com/company/architecture/product/ProductController.java b/source/src/main/java/com/company/architecture/product/ProductController.java new file mode 100644 index 0000000..7799766 --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/ProductController.java @@ -0,0 +1,66 @@ +package com.company.architecture.product; + +import com.company.architecture.product.dtos.*; +import com.company.architecture.shared.swagger.DefaultApiResponses; +import com.company.architecture.shared.swagger.GetApiResponses; +import com.company.architecture.shared.swagger.PostApiResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.UUID; + +@Tag(name = "Products") +@RequiredArgsConstructor +@RestController +@RequestMapping("/products") +public class ProductController { + private final ProductService productService; + + @Operation(summary = "Get") + @GetApiResponses + @GetMapping + public Page get(@ParameterObject @ModelAttribute @Valid final GetProductDto dto) { + return productService.get(dto); + } + + @Operation(summary = "Get") + @GetApiResponses + @GetMapping("{id}") + public ProductDto get(@PathVariable final UUID id) { + return productService.get(id); + } + + @Operation(summary = "Add") + @PostApiResponses + @PostMapping + public UUID add(@RequestBody @Valid final AddProductDto dto) { + return productService.add(dto); + } + + @Operation(summary = "Update") + @DefaultApiResponses + @PutMapping("{id}") + public void update(@PathVariable final UUID id, @RequestBody @Valid final UpdateProductDto dto) { + productService.update(dto.withId(id)); + } + + @Operation(summary = "Update Price") + @DefaultApiResponses + @PatchMapping("{id}/price/{price}") + public void update(@PathVariable final UUID id, @PathVariable final BigDecimal price) { + productService.update(new UpdatePriceProductDto(id, price)); + } + + @Operation(summary = "Delete") + @DefaultApiResponses + @DeleteMapping("{id}") + public void delete(@PathVariable final UUID id) { + productService.delete(id); + } +} diff --git a/source/src/main/java/com/company/architecture/product/ProductRepository.java b/source/src/main/java/com/company/architecture/product/ProductRepository.java new file mode 100644 index 0000000..0c4e45b --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/ProductRepository.java @@ -0,0 +1,10 @@ +package com.company.architecture.product; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface ProductRepository extends MongoRepository { +} diff --git a/source/src/main/java/com/company/architecture/product/ProductService.java b/source/src/main/java/com/company/architecture/product/ProductService.java new file mode 100644 index 0000000..feb959e --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/ProductService.java @@ -0,0 +1,48 @@ +package com.company.architecture.product; + +import com.company.architecture.product.dtos.*; +import com.company.architecture.shared.services.MapperService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ProductService { + private final MapperService mapperService; + private final ProductRepository productRepository; + + public Page get(final GetProductDto dto) { + final var products = productRepository.findAll(dto.getExample(Product.class), dto.getPageable()); + if (products.isEmpty()) throw new NoSuchElementException(); + return mapperService.mapPage(products, ProductDto.class); + } + + public ProductDto get(final UUID id) { + return productRepository.findById(id).map(product -> mapperService.map(product, ProductDto.class)).orElseThrow(); + } + + public UUID add(final AddProductDto dto) { + final var product = mapperService.map(dto, Product.class); + product.setId(UUID.randomUUID()); + return productRepository.insert(product).getId(); + } + + public void update(final UpdateProductDto dto) { + productRepository.save(mapperService.map(dto, Product.class)); + } + + public void update(final UpdatePriceProductDto dto) { + final var product = productRepository.findById(dto.id()).orElseThrow(); + BeanUtils.copyProperties(dto, product); + productRepository.save(product); + } + + public void delete(final UUID id) { + productRepository.deleteById(id); + } +} diff --git a/source/src/main/java/com/company/architecture/product/dtos/AddProductDto.java b/source/src/main/java/com/company/architecture/product/dtos/AddProductDto.java new file mode 100644 index 0000000..205579c --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/dtos/AddProductDto.java @@ -0,0 +1,11 @@ +package com.company.architecture.product.dtos; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.math.BigDecimal; + +public record AddProductDto( + @NotBlank String description, + @Min(0L) BigDecimal price) { +} diff --git a/source/src/main/java/com/company/architecture/product/dtos/GetProductDto.java b/source/src/main/java/com/company/architecture/product/dtos/GetProductDto.java new file mode 100644 index 0000000..88071da --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/dtos/GetProductDto.java @@ -0,0 +1,15 @@ +package com.company.architecture.product.dtos; + +import com.company.architecture.shared.dtos.PageableDto; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +public final class GetProductDto extends PageableDto { + private String description; + + public GetProductDto() { + setSort("description"); + } +} diff --git a/source/src/main/java/com/company/architecture/product/dtos/ProductDto.java b/source/src/main/java/com/company/architecture/product/dtos/ProductDto.java new file mode 100644 index 0000000..8b81760 --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/dtos/ProductDto.java @@ -0,0 +1,10 @@ +package com.company.architecture.product.dtos; + +import java.math.BigDecimal; +import java.util.UUID; + +public record ProductDto( + UUID id, + String description, + BigDecimal price) { +} diff --git a/source/src/main/java/com/company/architecture/product/dtos/UpdatePriceProductDto.java b/source/src/main/java/com/company/architecture/product/dtos/UpdatePriceProductDto.java new file mode 100644 index 0000000..3875b20 --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/dtos/UpdatePriceProductDto.java @@ -0,0 +1,12 @@ +package com.company.architecture.product.dtos; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.UUID; + +public record UpdatePriceProductDto( + @NotNull UUID id, + @Min(0L) BigDecimal price) { +} diff --git a/source/src/main/java/com/company/architecture/product/dtos/UpdateProductDto.java b/source/src/main/java/com/company/architecture/product/dtos/UpdateProductDto.java new file mode 100644 index 0000000..3909017 --- /dev/null +++ b/source/src/main/java/com/company/architecture/product/dtos/UpdateProductDto.java @@ -0,0 +1,18 @@ +package com.company.architecture.product.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.math.BigDecimal; +import java.util.UUID; + +public record UpdateProductDto( + @Schema(hidden = true) UUID id, + @NotBlank String description, + @Min(0L) BigDecimal price) { + + public UpdateProductDto withId(UUID id) { + return new UpdateProductDto(id, description, price); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/configurations/JacksonConfiguration.java b/source/src/main/java/com/company/architecture/shared/configurations/JacksonConfiguration.java new file mode 100644 index 0000000..fa212d5 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/configurations/JacksonConfiguration.java @@ -0,0 +1,29 @@ +package com.company.architecture.shared.configurations; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.web.config.SpringDataJacksonConfiguration.PageModule; +import org.springframework.data.web.config.SpringDataWebSettings; + +import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO; + +@Configuration +public class JacksonConfiguration { + @Bean + @Primary + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .registerModule(new PageModule(new SpringDataWebSettings(VIA_DTO))) + .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/database/DatabaseRunner.java b/source/src/main/java/com/company/architecture/shared/database/DatabaseRunner.java new file mode 100644 index 0000000..186d4e6 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/database/DatabaseRunner.java @@ -0,0 +1,28 @@ +package com.company.architecture.shared.database; + +import com.company.architecture.auth.Authority; +import com.company.architecture.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Profile("!test") +public class DatabaseRunner implements ApplicationRunner { + private final MongoTemplate mongoTemplate; + private final PasswordEncoder passwordEncoder; + + public void run(final ApplicationArguments args) { + if (!mongoTemplate.collectionExists("user")) { + mongoTemplate.save(new User(UUID.randomUUID(), "Admin", "admin@mail.com", "admin", passwordEncoder.encode("123456"), List.of(Authority.ADMINISTRATOR))); + } + } +} diff --git a/source/src/main/java/com/company/architecture/shared/dtos/PageableDto.java b/source/src/main/java/com/company/architecture/shared/dtos/PageableDto.java new file mode 100644 index 0000000..97b7758 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/dtos/PageableDto.java @@ -0,0 +1,33 @@ +package com.company.architecture.shared.dtos; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Data; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.ExampleMatcher.StringMatcher; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; + +@Data +public class PageableDto { + @PositiveOrZero + private int page; + + @Positive + private int size = 2_000_000_000; + + private String sort = "id"; + + private Direction direction = Direction.ASC; + + private Pageable pageable = PageRequest.of(page, size, direction, sort); + + public Example getExample(final Class clazz) { + var instance = BeanUtils.instantiateClass(clazz); + BeanUtils.copyProperties(this, instance); + return Example.of(instance, ExampleMatcher.matching().withIgnoreCase().withStringMatcher(StringMatcher.CONTAINING)); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/exception/ApplicationException.java b/source/src/main/java/com/company/architecture/shared/exception/ApplicationException.java new file mode 100644 index 0000000..17b0f03 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/exception/ApplicationException.java @@ -0,0 +1,17 @@ +package com.company.architecture.shared.exception; + +import com.company.architecture.shared.services.MessageService; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public class ApplicationException extends RuntimeException { + private final HttpStatus status; + + public ApplicationException(HttpStatus status, String message) { + super(MessageService.get(message)); + this.status = status; + } +} diff --git a/source/src/main/java/com/company/architecture/shared/exception/ControllerAdvice.java b/source/src/main/java/com/company/architecture/shared/exception/ControllerAdvice.java new file mode 100644 index 0000000..c4053fe --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/exception/ControllerAdvice.java @@ -0,0 +1,71 @@ +package com.company.architecture.shared.exception; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.*; + +@Slf4j +@RequiredArgsConstructor +@RestControllerAdvice +public class ControllerAdvice { + @ExceptionHandler(Exception.class) + public ResponseEntity handle(final Exception exception) { + log.error(exception.getMessage(), exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handle(final AccessDeniedException exception) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handle(final NoSuchElementException exception) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handle(MethodArgumentTypeMismatchException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("[%s: must be valid]".formatted(exception.getPropertyName())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handle(final MethodArgumentNotValidException exception) { + final List errors = new ArrayList<>(); + + for (final var error : exception.getBindingResult().getFieldErrors()) { + var message = error.getDefaultMessage(); + + if (Arrays.asList(Objects.requireNonNull(error.getCodes())).contains("typeMismatch")) { + message = "must be valid"; + } + + errors.add("%s: %s".formatted(error.getField(), message)); + } + + exception.getBindingResult().getGlobalErrors().forEach(error -> + errors.add("%s: %s".formatted(error.getObjectName(), error.getDefaultMessage())) + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors.stream().sorted().toList().toString()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handle(final HttpMessageNotReadableException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exception.getMessage()); + } + + @ExceptionHandler(ApplicationException.class) + public ResponseEntity handle(final ApplicationException exception) { + return ResponseEntity.status(exception.getStatus()).body(exception.getMessage()); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/services/MapperService.java b/source/src/main/java/com/company/architecture/shared/services/MapperService.java new file mode 100644 index 0000000..0ed5c5e --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/services/MapperService.java @@ -0,0 +1,40 @@ +package com.company.architecture.shared.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static java.util.Objects.isNull; + +@RequiredArgsConstructor +@Service +public class MapperService { + private final ObjectMapper objectMapper; + + public T map(Object source, Class target) { + return objectMapper.convertValue(source, target); + } + + public List mapList(@Nullable List source, Class target) { + return isNull(source) ? Collections.emptyList() : source.stream().map(entity -> map(entity, target)).toList(); + } + + public Page mapPage(Page source, Class target) { + return isNull(source) ? null : new PageImpl<>(source.stream().map(item -> map(item, target)).toList(), source.getPageable(), source.getTotalElements()); + } + + public String toJson(Object source) throws JsonProcessingException { + return isNull(source) ? null : objectMapper.writeValueAsString(source); + } + + public T fromJson(String source, Class target) throws JsonProcessingException { + return isNull(source) ? null : objectMapper.readValue(source, target); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/services/MessageService.java b/source/src/main/java/com/company/architecture/shared/services/MessageService.java new file mode 100644 index 0000000..ad3c67f --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/services/MessageService.java @@ -0,0 +1,18 @@ +package com.company.architecture.shared.services; + +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.stereotype.Service; + +@Service +public class MessageService { + private static final ResourceBundleMessageSource source = new ResourceBundleMessageSource(); + + public MessageService() { + source.setBasename("messages"); + } + + public static String get(final String message, String... args) { + return source.getMessage(message, args, message, LocaleContextHolder.getLocale()); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/services/ValidatorService.java b/source/src/main/java/com/company/architecture/shared/services/ValidatorService.java new file mode 100644 index 0000000..267197f --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/services/ValidatorService.java @@ -0,0 +1,39 @@ +package com.company.architecture.shared.services; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +@Service +public class ValidatorService { + private static boolean cpfVerifier(CharSequence value, int length) { + final var verifier = IntStream.range(0, length).map(i -> Character.getNumericValue(value.charAt(i)) * (length + 1 - i)).sum() * 10 % 11 % 10; + return verifier == Character.getNumericValue(value.charAt(length)); + } + + private static boolean cnpjVerifier(CharSequence value, int index) { + final var weights = new ArrayList<>(List.of(6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2)); + if (index == 12) weights.removeFirst(); + final var sum = IntStream.range(0, index).map(i -> Character.getNumericValue(value.charAt(i)) * weights.get(i)).sum(); + final var verifier = ((sum % 11) < 2) ? 0 : (11 - (sum % 11)); + return verifier == Character.getNumericValue(value.charAt(index)); + } + + public List> validate(Object object) { + try (final var validatorFactory = Validation.buildDefaultValidatorFactory()) { + return validatorFactory.getValidator().validate(object).stream().toList(); + } + } + + public boolean validateCpf(String value) { + return (value = value.replaceAll("\\D", "")).matches("\\d{11}") && !value.matches("(\\d)\\1{10}") && cpfVerifier(value, 9) && cpfVerifier(value, 10); + } + + public boolean validateCnpj(String value) { + return (value = value.replaceAll("\\D", "")).matches("\\d{14}") && !value.matches("(\\d)\\1{13}") && cnpjVerifier(value, 12) && cnpjVerifier(value, 13); + } +} diff --git a/source/src/main/java/com/company/architecture/shared/swagger/BaseApiResponses.java b/source/src/main/java/com/company/architecture/shared/swagger/BaseApiResponses.java new file mode 100644 index 0000000..a2a3461 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/swagger/BaseApiResponses.java @@ -0,0 +1,21 @@ +package com.company.architecture.shared.swagger; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.ANNOTATION_TYPE) +@ApiResponse(responseCode = "400", content = @Content) +@ApiResponse(responseCode = "401", content = @Content) +@ApiResponse(responseCode = "403", content = @Content) +@ApiResponse(responseCode = "404", content = @Content) +@ApiResponse(responseCode = "422", content = @Content) +@ApiResponse(responseCode = "500", content = @Content) +@ApiResponse(responseCode = "503", content = @Content) +public @interface BaseApiResponses { +} diff --git a/source/src/main/java/com/company/architecture/shared/swagger/DefaultApiResponses.java b/source/src/main/java/com/company/architecture/shared/swagger/DefaultApiResponses.java new file mode 100644 index 0000000..c07b5fd --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/swagger/DefaultApiResponses.java @@ -0,0 +1,18 @@ +package com.company.architecture.shared.swagger; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@BaseApiResponses +@ApiResponse(responseCode = "200", content = @Content) +@ApiResponse(responseCode = "202", content = @Content) +@ApiResponse(responseCode = "204", content = @Content) +public @interface DefaultApiResponses { +} diff --git a/source/src/main/java/com/company/architecture/shared/swagger/GetApiResponses.java b/source/src/main/java/com/company/architecture/shared/swagger/GetApiResponses.java new file mode 100644 index 0000000..f3e2843 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/swagger/GetApiResponses.java @@ -0,0 +1,15 @@ +package com.company.architecture.shared.swagger; + +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@BaseApiResponses +@ApiResponse(responseCode = "200") +public @interface GetApiResponses { +} diff --git a/source/src/main/java/com/company/architecture/shared/swagger/PostApiResponses.java b/source/src/main/java/com/company/architecture/shared/swagger/PostApiResponses.java new file mode 100644 index 0000000..bc6ddea --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/swagger/PostApiResponses.java @@ -0,0 +1,21 @@ +package com.company.architecture.shared.swagger; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@BaseApiResponses +@ApiResponse(responseCode = "201") +@ApiResponse(responseCode = "202", content = @Content) +@ApiResponse(responseCode = "204", content = @Content) +@ResponseStatus(HttpStatus.CREATED) +public @interface PostApiResponses { +} diff --git a/source/src/main/java/com/company/architecture/shared/swagger/SwaggerConfiguration.java b/source/src/main/java/com/company/architecture/shared/swagger/SwaggerConfiguration.java new file mode 100644 index 0000000..01ff5b7 --- /dev/null +++ b/source/src/main/java/com/company/architecture/shared/swagger/SwaggerConfiguration.java @@ -0,0 +1,18 @@ +package com.company.architecture.shared.swagger; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfiguration { + @Bean + OpenAPI openAPI() { + final var securityScheme = new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"); + final var components = new Components().addSecuritySchemes("JWT", securityScheme); + return new OpenAPI().components(components).addSecurityItem(new SecurityRequirement().addList("JWT")); + } +} diff --git a/source/src/main/java/com/company/architecture/user/User.java b/source/src/main/java/com/company/architecture/user/User.java new file mode 100644 index 0000000..affeadc --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/User.java @@ -0,0 +1,42 @@ +package com.company.architecture.user; + +import com.company.architecture.auth.Authority; +import jakarta.persistence.Id; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Data +@Document +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class User { + @Id + @EqualsAndHashCode.Include + private UUID id; + + @NotBlank + private String name; + + @NotBlank + @Email + @Indexed(unique = true) + private String email; + + @NotBlank + @Indexed(unique = true) + private String username; + + @NotBlank + private String password; + + @NotBlank + public List authorities; +} diff --git a/source/src/main/java/com/company/architecture/user/UserController.java b/source/src/main/java/com/company/architecture/user/UserController.java new file mode 100644 index 0000000..06da7c4 --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/UserController.java @@ -0,0 +1,61 @@ +package com.company.architecture.user; + +import com.company.architecture.shared.swagger.DefaultApiResponses; +import com.company.architecture.shared.swagger.GetApiResponses; +import com.company.architecture.shared.swagger.PostApiResponses; +import com.company.architecture.user.dtos.AddUserDto; +import com.company.architecture.user.dtos.GetUserDto; +import com.company.architecture.user.dtos.UpdateUserDto; +import com.company.architecture.user.dtos.UserDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@Tag(name = "Users") +@RequiredArgsConstructor +@RestController +@RequestMapping("/users") +public class UserController { + private final UserService userService; + + @Operation(summary = "Get") + @GetApiResponses + @GetMapping + public Page get(@ParameterObject @ModelAttribute @Valid final GetUserDto dto) { + return userService.get(dto); + } + + @Operation(summary = "Get") + @GetApiResponses + @GetMapping("{id}") + public UserDto get(@PathVariable final UUID id) { + return userService.get(id); + } + + @Operation(summary = "Add") + @PostApiResponses + @PostMapping + public UUID add(@RequestBody @Valid final AddUserDto dto) { + return userService.add(dto); + } + + @Operation(summary = "Update") + @DefaultApiResponses + @PutMapping("{id}") + public void update(@PathVariable final UUID id, @RequestBody @Valid final UpdateUserDto dto) { + userService.update(dto.withId(id)); + } + + @Operation(summary = "Delete") + @DefaultApiResponses + @DeleteMapping("{id}") + public void delete(@PathVariable final UUID id) { + userService.delete(id); + } +} diff --git a/source/src/main/java/com/company/architecture/user/UserRepository.java b/source/src/main/java/com/company/architecture/user/UserRepository.java new file mode 100644 index 0000000..6b14f3c --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/UserRepository.java @@ -0,0 +1,18 @@ +package com.company.architecture.user; + +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends MongoRepository { + boolean existsByEmailOrUsername(String email, String username); + + @Query(value = "{$and: [{'_id': {$ne: ?0}}, {$or: [{'email': ?1}, {'username': ?2}]}]}", exists = true) + boolean existsByEmailOrUsername(UUID id, String email, String username); + + Optional findByUsername(String username); +} diff --git a/source/src/main/java/com/company/architecture/user/UserService.java b/source/src/main/java/com/company/architecture/user/UserService.java new file mode 100644 index 0000000..90215aa --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/UserService.java @@ -0,0 +1,59 @@ +package com.company.architecture.user; + +import com.company.architecture.auth.Authority; +import com.company.architecture.shared.exception.ApplicationException; +import com.company.architecture.shared.services.MapperService; +import com.company.architecture.user.dtos.AddUserDto; +import com.company.architecture.user.dtos.GetUserDto; +import com.company.architecture.user.dtos.UpdateUserDto; +import com.company.architecture.user.dtos.UserDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService { + private final PasswordEncoder passwordEncoder; + private final MapperService mapperService; + private final UserRepository userRepository; + + public Page get(final GetUserDto dto) { + final var users = userRepository.findAll(dto.getExample(User.class), dto.getPageable()); + if (users.isEmpty()) throw new NoSuchElementException(); + return mapperService.mapPage(users, UserDto.class); + } + + public UserDto get(UUID id) { + return userRepository.findById(id).map(user -> mapperService.map(user, UserDto.class)).orElseThrow(); + } + + + public UUID add(final AddUserDto dto) { + final var exists = userRepository.existsByEmailOrUsername(dto.email(), dto.username()); + if (exists) throw new ApplicationException(HttpStatus.CONFLICT); + final var user = mapperService.map(dto, User.class); + user.setId(UUID.randomUUID()); + user.setPassword(passwordEncoder.encode(dto.password())); + user.setAuthorities(List.of(Authority.DEFAULT)); + return userRepository.insert(user).getId(); + } + + public void update(final UpdateUserDto dto) { + final var exists = userRepository.existsByEmailOrUsername(dto.id(), dto.email(), dto.username()); + if (exists) throw new ApplicationException(HttpStatus.CONFLICT); + final var user = mapperService.map(dto, User.class); + user.setPassword(passwordEncoder.encode(dto.password())); + userRepository.save(user); + } + + public void delete(final UUID id) { + userRepository.deleteById(id); + } +} diff --git a/source/src/main/java/com/company/architecture/user/dtos/AddUserDto.java b/source/src/main/java/com/company/architecture/user/dtos/AddUserDto.java new file mode 100644 index 0000000..6bf7e4a --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/dtos/AddUserDto.java @@ -0,0 +1,11 @@ +package com.company.architecture.user.dtos; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record AddUserDto( + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank String username, + @NotBlank String password) { +} diff --git a/source/src/main/java/com/company/architecture/user/dtos/GetUserDto.java b/source/src/main/java/com/company/architecture/user/dtos/GetUserDto.java new file mode 100644 index 0000000..5f4beea --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/dtos/GetUserDto.java @@ -0,0 +1,15 @@ +package com.company.architecture.user.dtos; + +import com.company.architecture.shared.dtos.PageableDto; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +public final class GetUserDto extends PageableDto { + private String name; + + public GetUserDto() { + setSort("name"); + } +} diff --git a/source/src/main/java/com/company/architecture/user/dtos/UpdateUserDto.java b/source/src/main/java/com/company/architecture/user/dtos/UpdateUserDto.java new file mode 100644 index 0000000..69843ae --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/dtos/UpdateUserDto.java @@ -0,0 +1,19 @@ +package com.company.architecture.user.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +import java.util.UUID; + +public record UpdateUserDto( + @Schema(hidden = true) UUID id, + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank String username, + @NotBlank String password) { + + public UpdateUserDto withId(UUID id) { + return new UpdateUserDto(id, name, email, username, password); + } +} diff --git a/source/src/main/java/com/company/architecture/user/dtos/UserDto.java b/source/src/main/java/com/company/architecture/user/dtos/UserDto.java new file mode 100644 index 0000000..9517ecb --- /dev/null +++ b/source/src/main/java/com/company/architecture/user/dtos/UserDto.java @@ -0,0 +1,9 @@ +package com.company.architecture.user.dtos; + +import java.util.UUID; + +public record UserDto( + UUID id, + String name, + String email) { +} diff --git a/source/src/main/resources/application.yml b/source/src/main/resources/application.yml new file mode 100644 index 0000000..f655bdc --- /dev/null +++ b/source/src/main/resources/application.yml @@ -0,0 +1,68 @@ +spring: + jpa: + hibernate: + ddl-auto: create + open-in-view: false + properties: + hibernate: + enable_lazy_load_no_trans: true + datasource: + driver-class-name: org.postgresql.Driver + url: "${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/database}" + username: "${SPRING_DATASOURCE_USERNAME:admin}" + password: "${SPRING_DATASOURCE_PASSWORD:password}" + data: + mongodb: + uuid-representation: standard + uri: >- + ${SPRING_DATA_MONGODB_URI:mongodb://admin:password@localhost:27017/database?authSource=admin} + kafka: + bootstrap-servers: "${SPRING_KAFKA:localhost:9092}" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + auto-offset-reset: latest + properties: + spring: + json: + trusted: + packages: "*" + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + cloud: + aws: + s3: + bucket: "${SPRING_CLOUD_AWS_S3_BUCKET:bucket}" + sqs: + queue: "${SPRING_CLOUD_AWS_SQS_QUEUE:queue}" +springdoc: + swagger-ui: + path: / + docExpansion: none + filter: true + tagsSorter: alpha + operationsSorter: alpha +management: + endpoints: + enabled-by-default: false + web: + exposure: + include: "health,metrics" + endpoint: + health: + enabled: true + metrics: + enabled: true + tracing: + baggage: + enabled: true + enabled: true + propagation: + type: w3c + sampling: + probability: "1.0" +logging: + pattern: + console: >- + [%d{yyyy-MM-dd}] [%d{HH:mm:ss}] %highlight([%level]) %cyan([%logger]): %msg %n%n diff --git a/source/src/main/resources/messages.properties b/source/src/main/resources/messages.properties new file mode 100644 index 0000000..f73f5a9 --- /dev/null +++ b/source/src/main/resources/messages.properties @@ -0,0 +1 @@ +auth.unauthorized=Invalid username and password. diff --git a/source/src/main/resources/messages_pt_BR.properties b/source/src/main/resources/messages_pt_BR.properties new file mode 100644 index 0000000..953ca7f --- /dev/null +++ b/source/src/main/resources/messages_pt_BR.properties @@ -0,0 +1 @@ +auth.unauthorized=Usu\u00e1rio e senha inv\u00e1lidos. diff --git a/source/src/test/java/com/company/architecture/ControllerTest.java b/source/src/test/java/com/company/architecture/ControllerTest.java new file mode 100644 index 0000000..813f1b7 --- /dev/null +++ b/source/src/test/java/com/company/architecture/ControllerTest.java @@ -0,0 +1,54 @@ +package com.company.architecture; + +import com.company.architecture.auth.Authority; +import com.company.architecture.auth.JwtService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; + +public abstract class ControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected JwtService jwtService; + + @BeforeEach + protected void beforeEach() { + when(jwtService.getSubject(anyString())).thenReturn(UUID.randomUUID().toString()); + when(jwtService.getAuthorities(anyString())).thenReturn(createAuthorityList(Authority.ADMINISTRATOR.toString())); + when(jwtService.create(any())).thenReturn(""); + when(jwtService.verify(anyString())).thenReturn(true); + } + + protected ResultActions perform(HttpMethod method, String uri) throws Exception { + final var builder = MockMvcRequestBuilders.request(method, uri).contentType(MediaType.APPLICATION_JSON); + return mockMvc.perform(builder).andDo(result -> System.out.printf(" Uri: %s%n Method: %s%n Response: %s%n%n", uri, method, result.getResponse().getContentAsString())); + } + + protected ResultActions perform(HttpMethod method, String uri, Object body) throws Exception { + final var builder = MockMvcRequestBuilders.request(method, uri).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(body)); + return mockMvc.perform(builder).andDo(result -> System.out.printf(" Uri: %s%n Method: %s%n Request: %s%n Response: %s%n%n", uri, method, body, result.getResponse().getContentAsString())); + } + + protected ResultActions multipart(String uri, MockMultipartFile file) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders.multipart(uri).file(file)); + } +} diff --git a/source/src/test/java/com/company/architecture/IntegrationTest.java b/source/src/test/java/com/company/architecture/IntegrationTest.java new file mode 100644 index 0000000..c4f60df --- /dev/null +++ b/source/src/test/java/com/company/architecture/IntegrationTest.java @@ -0,0 +1,58 @@ +package com.company.architecture; + +import com.company.architecture.shared.services.MapperService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +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.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.kafka.KafkaContainer; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +public abstract class IntegrationTest extends ControllerTest { + @Container + protected static final KafkaContainer kafka = KafkaTest.kafka; + + @Container + static final LocalStackContainer localstack = LocalStackTest.localstack; + + @ServiceConnection + static final MongoDBContainer mongo = MongoTest.mongo; + + @ServiceConnection + static final PostgreSQLContainer postgre = PostgreTest.postgre; + + @Autowired + protected MongoTemplate mongoTemplate; + + @Autowired + protected MapperService mapperService; + + @DynamicPropertySource + static void dynamicPropertySource(DynamicPropertyRegistry registry) { + KafkaTest.registry(kafka, registry); + LocalStackTest.registry(localstack, registry); + } + + @BeforeAll + static void beforeAll() { + KafkaTest.beforeAll(); + LocalStackTest.beforeAll(); + MongoTest.beforeAll(); + PostgreTest.beforeAll(); + } + + @BeforeEach + void before() { + mongoTemplate.getDb().drop(); + } +} diff --git a/source/src/test/java/com/company/architecture/KafkaTest.java b/source/src/test/java/com/company/architecture/KafkaTest.java new file mode 100644 index 0000000..2f925c1 --- /dev/null +++ b/source/src/test/java/com/company/architecture/KafkaTest.java @@ -0,0 +1,28 @@ +package com.company.architecture; + +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.kafka.KafkaContainer; + +@DirtiesContext +public abstract class KafkaTest { + @Container + protected static final KafkaContainer kafka = new KafkaContainer("apache/kafka"); + + @DynamicPropertySource + static void dynamicPropertySource(DynamicPropertyRegistry registry) { + registry(kafka, registry); + } + + @BeforeAll + static void beforeAll() { + kafka.start(); + } + + static void registry(KafkaContainer container, DynamicPropertyRegistry registry) { + registry.add("spring.kafka.bootstrap-servers", container::getBootstrapServers); + } +} diff --git a/source/src/test/java/com/company/architecture/LocalStackTest.java b/source/src/test/java/com/company/architecture/LocalStackTest.java new file mode 100644 index 0000000..379b851 --- /dev/null +++ b/source/src/test/java/com/company/architecture/LocalStackTest.java @@ -0,0 +1,35 @@ +package com.company.architecture; + +import org.junit.jupiter.api.BeforeAll; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; + +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +public abstract class LocalStackTest { + @Container + static final LocalStackContainer localstack = new LocalStackContainer(DockerImageName.parse("localstack/localstack")).withServices(LocalStackContainer.Service.SQS, LocalStackContainer.EnabledService.named("sqs-query"), LocalStackContainer.Service.S3); + + @DynamicPropertySource + static void dynamicPropertySource(DynamicPropertyRegistry registry) { + registry(localstack, registry); + } + + @BeforeAll + static void beforeAll() { + localstack.start(); + } + + static void registry(LocalStackContainer container, DynamicPropertyRegistry registry) { + System.setProperty("aws.endpoint", container.getEndpoint().toString()); + System.setProperty("aws.region", container.getRegion()); + System.setProperty("aws.accessKeyId", container.getAccessKey()); + System.setProperty("aws.secretAccessKey", container.getSecretKey()); + } +} diff --git a/source/src/test/java/com/company/architecture/MongoTest.java b/source/src/test/java/com/company/architecture/MongoTest.java new file mode 100644 index 0000000..7426096 --- /dev/null +++ b/source/src/test/java/com/company/architecture/MongoTest.java @@ -0,0 +1,28 @@ +package com.company.architecture; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.testcontainers.containers.MongoDBContainer; + +@DataMongoTest +public abstract class MongoTest { + @ServiceConnection + static final MongoDBContainer mongo = new MongoDBContainer("mongo"); + + @Autowired + protected MongoTemplate mongoTemplate; + + @BeforeAll + static void beforeAll() { + mongo.start(); + } + + @BeforeEach + void beforeEach() { + mongoTemplate.getDb().drop(); + } +} diff --git a/source/src/test/java/com/company/architecture/PostgreTest.java b/source/src/test/java/com/company/architecture/PostgreTest.java new file mode 100644 index 0000000..a1135af --- /dev/null +++ b/source/src/test/java/com/company/architecture/PostgreTest.java @@ -0,0 +1,26 @@ +package com.company.architecture; + +import org.junit.jupiter.api.BeforeAll; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.containers.PostgreSQLContainer; + +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DataJpaTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +public abstract class PostgreTest { + @ServiceConnection + static final PostgreSQLContainer postgre = new PostgreSQLContainer<>("postgres"); + + @Autowired + protected TestEntityManager testEntityManager; + + @BeforeAll + static void beforeAll() { + postgre.start(); + } +} diff --git a/source/src/test/java/com/company/architecture/auth/AuthIntegrationTest.java b/source/src/test/java/com/company/architecture/auth/AuthIntegrationTest.java new file mode 100644 index 0000000..6d0496b --- /dev/null +++ b/source/src/test/java/com/company/architecture/auth/AuthIntegrationTest.java @@ -0,0 +1,44 @@ +package com.company.architecture.auth; + +import com.company.architecture.IntegrationTest; +import com.company.architecture.shared.Data; +import com.company.architecture.user.UserRepository; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AuthIntegrationTest extends IntegrationTest { + @MockBean + UserRepository userRepository; + + private static Stream parameters() { + return Stream.of( + arguments(null, HttpStatus.BAD_REQUEST), + arguments(new AuthDto(null, null), HttpStatus.BAD_REQUEST), + arguments(new AuthDto("", ""), HttpStatus.BAD_REQUEST), + arguments(new AuthDto(UUID.randomUUID().toString(), UUID.randomUUID().toString()), HttpStatus.UNAUTHORIZED), + arguments(new AuthDto(Data.USER.getUsername(), UUID.randomUUID().toString()), HttpStatus.UNAUTHORIZED), + arguments(new AuthDto(UUID.randomUUID().toString(), Data.PASSWORD), HttpStatus.UNAUTHORIZED), + arguments(new AuthDto(Data.USER.getUsername(), Data.PASSWORD), HttpStatus.OK) + ); + } + + @ParameterizedTest + @MethodSource("parameters") + void shouldReturnExpectedHttpStatusForPostRequestWhenAuthDtoIsProvided(AuthDto authDto, HttpStatusCode statusCode) throws Exception { + when(userRepository.findByUsername(Data.USER.getUsername())).thenReturn(Optional.of(Data.USER)); + perform(POST, "/auth", authDto).andExpectAll(status().is(statusCode.value())); + } +} diff --git a/source/src/test/java/com/company/architecture/auth/JwtServiceTest.java b/source/src/test/java/com/company/architecture/auth/JwtServiceTest.java new file mode 100644 index 0000000..5df31f8 --- /dev/null +++ b/source/src/test/java/com/company/architecture/auth/JwtServiceTest.java @@ -0,0 +1,54 @@ +package com.company.architecture.auth; + +import com.company.architecture.shared.Data; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.authority.AuthorityUtils; + +import java.util.UUID; + +@SpringBootTest(classes = JwtService.class) +class JwtServiceTest { + @Autowired + JwtService jwtService; + + @ParameterizedTest + @NullAndEmptySource + void shouldReturnFalseWhenTokenIsNullOrEmpty(String token) { + Assertions.assertFalse(jwtService.verify(token)); + } + + @Test + void shouldReturnFalseForInvalidToken() { + Assertions.assertFalse(jwtService.verify(UUID.randomUUID().toString())); + } + + @Test + void shouldReturnTrueForValidToken() { + Assertions.assertTrue(jwtService.verify(jwtService.create(Data.USER))); + } + + @Test + void shouldNotReturnInvalidSubjectForValidToken() { + Assertions.assertNotEquals("Invalid", jwtService.getSubject(jwtService.create(Data.USER))); + } + + @Test + void shouldReturnNonNullSubjectForValidToken() { + Assertions.assertNotNull(jwtService.getSubject(jwtService.create(Data.USER))); + } + + @Test + void shouldNotReturnInvalidAuthoritiesForValidToken() { + Assertions.assertNotEquals(AuthorityUtils.createAuthorityList("Invalid"), jwtService.getAuthorities(jwtService.create(Data.USER))); + } + + @Test + void shouldReturnCorrectAuthoritiesForValidToken() { + Assertions.assertEquals(AuthorityUtils.createAuthorityList("ADMINISTRATOR"), jwtService.getAuthorities(jwtService.create(Data.USER))); + } +} diff --git a/source/src/test/java/com/company/architecture/aws/AwsIntegrationTest.java b/source/src/test/java/com/company/architecture/aws/AwsIntegrationTest.java new file mode 100644 index 0000000..7ff996e --- /dev/null +++ b/source/src/test/java/com/company/architecture/aws/AwsIntegrationTest.java @@ -0,0 +1,25 @@ +package com.company.architecture.aws; + +import com.company.architecture.IntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayInputStream; + +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AwsIntegrationTest extends IntegrationTest { + @Test + void shouldReturnCreatedStatusWhenSendingMessage() throws Exception { + perform(POST, "/aws/queues/send", "Message").andExpectAll(status().isCreated()); + } + + @Test + void shouldUploadAndDownloadFileSuccessfully() throws Exception { + final var file = new MockMultipartFile("file", "Test.txt", "text/plain", new ByteArrayInputStream("Test".getBytes())); + multipart("/aws/files/upload", file).andExpectAll(status().isCreated()); + perform(GET, "/aws/files/download/Test.txt").andExpectAll(status().isOk()); + } +} diff --git a/source/src/test/java/com/company/architecture/aws/AwsS3ServiceTest.java b/source/src/test/java/com/company/architecture/aws/AwsS3ServiceTest.java new file mode 100644 index 0000000..a7838e5 --- /dev/null +++ b/source/src/test/java/com/company/architecture/aws/AwsS3ServiceTest.java @@ -0,0 +1,74 @@ +package com.company.architecture.aws; + +import com.company.architecture.LocalStackTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +@SpringBootTest(classes = {AwsConfiguration.class, AwsS3Service.class}) +class AwsS3ServiceTest extends LocalStackTest { + @Autowired + AwsS3Service awsS3Service; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + @Test + void shouldThrowExceptionWhenStoringInInvalidBucket() { + Assertions.assertThrows(Exception.class, () -> awsS3Service.store("INVALID", "123", "Object")); + } + + @Test + void shouldNotThrowExceptionWhenStoringInValidBucket() { + Assertions.assertDoesNotThrow(() -> awsS3Service.store(bucket, "123", "Object")); + } + + @Test + void shouldThrowExceptionWhenReadingFromInvalidBucket() { + Assertions.assertThrows(Exception.class, () -> awsS3Service.read("INVALID", "123", String.class)); + } + + @Test + void shouldThrowExceptionWhenReadingWithInvalidKey() { + Assertions.assertThrows(Exception.class, () -> awsS3Service.read(bucket, "INVALID", String.class)); + } + + @Test + void shouldNotThrowExceptionWhenReadingFromValidBucketAndKey() { + Assertions.assertDoesNotThrow(() -> awsS3Service.store(bucket, "123", "Object")); + Assertions.assertDoesNotThrow(() -> awsS3Service.read(bucket, "123", String.class)); + } + + @Test + void shouldNotThrowExceptionWhenUploadingFile() throws IOException { + final var file = new MockMultipartFile("file", "Test.txt", "text/plain", new ByteArrayInputStream("Test".getBytes())); + Assertions.assertDoesNotThrow(() -> awsS3Service.upload(bucket, file)); + } + + @Test + void shouldThrowExceptionWhenUploadingBytesToInvalidBucket() { + Assertions.assertThrows(Exception.class, () -> awsS3Service.upload("INVALID", "Test.txt", "Object".getBytes())); + } + + @Test + void shouldThrowExceptionWhenUploadingInvalidBytes() { + Assertions.assertThrows(Exception.class, () -> awsS3Service.upload(bucket, "Test.txt", null)); + } + + @Test + void shouldNotThrowExceptionWhenUploadingValidBytes() { + Assertions.assertDoesNotThrow(() -> awsS3Service.upload(bucket, "Test.txt", "Object".getBytes())); + } + + @Test + void shouldNotThrowExceptionWhenDownloadingFile() { + Assertions.assertDoesNotThrow(() -> awsS3Service.upload(bucket, "Test.txt", "Object".getBytes())); + Assertions.assertDoesNotThrow(() -> awsS3Service.download(bucket, "Test.txt")); + } +} diff --git a/source/src/test/java/com/company/architecture/aws/AwsServiceTest.java b/source/src/test/java/com/company/architecture/aws/AwsServiceTest.java new file mode 100644 index 0000000..3e97585 --- /dev/null +++ b/source/src/test/java/com/company/architecture/aws/AwsServiceTest.java @@ -0,0 +1,34 @@ +package com.company.architecture.aws; + +import com.company.architecture.LocalStackTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +@SpringBootTest(classes = {AwsConfiguration.class, AwsSqsService.class, AwsS3Service.class, AwsService.class}) +class AwsServiceTest extends LocalStackTest { + @Autowired + AwsService awsService; + + @Test + void shouldNotThrowExceptionWhenSendingMessage() { + Assertions.assertDoesNotThrow(() -> awsService.send("Message")); + } + + @Test + void shouldNotThrowExceptionWhenListeningForMessage() { + Assertions.assertDoesNotThrow(() -> awsService.listen("Message")); + } + + @Test + void shouldNotThrowExceptionWhenUploadingAndDownloadingFile() throws IOException { + final var file = new MockMultipartFile("file", "Test.txt", "text/plain", new ByteArrayInputStream("Test".getBytes())); + Assertions.assertDoesNotThrow(() -> awsService.upload(file)); + Assertions.assertDoesNotThrow(() -> awsService.download("Test.txt")); + } +} diff --git a/source/src/test/java/com/company/architecture/aws/AwsSqsServiceTest.java b/source/src/test/java/com/company/architecture/aws/AwsSqsServiceTest.java new file mode 100644 index 0000000..b68b1ff --- /dev/null +++ b/source/src/test/java/com/company/architecture/aws/AwsSqsServiceTest.java @@ -0,0 +1,22 @@ +package com.company.architecture.aws; + +import com.company.architecture.LocalStackTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = {AwsConfiguration.class, AwsSqsService.class}) +class AwsSqsServiceTest extends LocalStackTest { + @Autowired + AwsSqsService awsSqsService; + + @Value("${spring.cloud.aws.sqs.queue}") + private String queue; + + @Test + void shouldNotThrowExceptionWhenSendingMessageToQueue() { + Assertions.assertDoesNotThrow(() -> awsSqsService.send(queue, "Message")); + } +} diff --git a/source/src/test/java/com/company/architecture/category/CategoryServiceTest.java b/source/src/test/java/com/company/architecture/category/CategoryServiceTest.java new file mode 100644 index 0000000..41fa8b1 --- /dev/null +++ b/source/src/test/java/com/company/architecture/category/CategoryServiceTest.java @@ -0,0 +1,17 @@ +package com.company.architecture.category; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = {CategoryService.class, CategoryCacheService.class, CategoryRepository.class}) +class CategoryServiceTest { + @Autowired + CategoryService categoryService; + + @Test + void shouldNotThrowExceptionWhenListingCategories() { + Assertions.assertDoesNotThrow(() -> categoryService.list()); + } +} diff --git a/source/src/test/java/com/company/architecture/game/GameServiceTest.java b/source/src/test/java/com/company/architecture/game/GameServiceTest.java new file mode 100644 index 0000000..808bc5e --- /dev/null +++ b/source/src/test/java/com/company/architecture/game/GameServiceTest.java @@ -0,0 +1,19 @@ +package com.company.architecture.game; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = {GameService.class, GameRepository.class}) +class GameServiceTest { + @Autowired + GameService gameService; + + @Test + void shouldReturnGamesMatchingTitleWhenListingGames() { + final var title = "Game A"; + final var games = gameService.list(new Game(title)); + Assertions.assertEquals(title, games.getFirst().title()); + } +} diff --git a/source/src/test/java/com/company/architecture/game/MockGameServiceTest.java b/source/src/test/java/com/company/architecture/game/MockGameServiceTest.java new file mode 100644 index 0000000..ebad68e --- /dev/null +++ b/source/src/test/java/com/company/architecture/game/MockGameServiceTest.java @@ -0,0 +1,30 @@ +package com.company.architecture.game; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; + +@SpringBootTest(classes = GameService.class) +@MockBean(GameRepository.class) +class MockGameServiceTest { + @Autowired + GameService gameService; + + @Autowired + GameRepository gameRepository; + + @Test + void shouldReturnAllGamesWhenListingWithAnyTitle() { + final var games = List.of(new Game("Game X"), new Game("Game Y"), new Game("Game Z")); + Mockito.when(gameRepository.list(ArgumentMatchers.any())).thenReturn(games); + Assertions.assertEquals(games.size(), gameService.list(new Game("A")).size()); + Assertions.assertEquals(games.size(), gameService.list(new Game("B")).size()); + Assertions.assertEquals(games.size(), gameService.list(new Game("C")).size()); + } +} diff --git a/source/src/test/java/com/company/architecture/group/GroupServiceTest.java b/source/src/test/java/com/company/architecture/group/GroupServiceTest.java new file mode 100644 index 0000000..e554189 --- /dev/null +++ b/source/src/test/java/com/company/architecture/group/GroupServiceTest.java @@ -0,0 +1,69 @@ +package com.company.architecture.group; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +@SpringBootTest(classes = {GroupService.class}) +class GroupServiceTest { + @Autowired + GroupService groupService; + + @Test + void shouldReturnEmptyGroupWhenGroupingByInvalidProperty() { + final var date = LocalDate.of(2100, 1, 1); + final var person = new Person(); + person.setPassport("12345"); + person.setPassportExpirationDate(date); + person.setDriverLicense("67890"); + person.setDriverLicenseExpirationDate(date); + final var groups = groupService.groupPropertiesBy(person, "Invalid"); + Assertions.assertEquals(0, groups.size()); + } + + @Test + void shouldGroupPropertiesBySameDateCorrectly() { + final var date = LocalDate.of(2100, 1, 1); + final var person = new Person(); + person.setPassport("12345"); + person.setPassportExpirationDate(date); + person.setDriverLicense("67890"); + person.setDriverLicenseExpirationDate(date); + final var groups = groupService.groupPropertiesBy(person, "ExpirationDate"); + final var values = groups.get(date).values().toArray(); + Assertions.assertEquals(1, groups.size()); + Assertions.assertEquals(person.getPassport(), values[0]); + Assertions.assertEquals(person.getDriverLicense(), values[1]); + } + + @Test + void shouldGroupPropertiesByDifferentDatesCorrectly() { + final var person = new Person(); + person.setPassport("12345"); + person.setPassportExpirationDate(LocalDate.of(2100, 1, 1)); + person.setDriverLicense("67890"); + person.setDriverLicenseExpirationDate(LocalDate.of(2200, 1, 1)); + final var groups = groupService.groupPropertiesBy(person, "ExpirationDate"); + Assertions.assertEquals(2, groups.size()); + Assertions.assertEquals(person.getPassport(), groups.get(person.getPassportExpirationDate()).values().toArray()[0]); + Assertions.assertEquals(person.getDriverLicense(), groups.get(person.getDriverLicenseExpirationDate()).values().toArray()[0]); + } + + @Test + void shouldSetPropertiesFromGroupCorrectly() { + final var date = LocalDate.of(2100, 1, 1); + final var person = new Person(); + person.setPassport("ABCDE"); + person.setDriverLicense("FGHIJ"); + final var personUpdate = new Person(); + personUpdate.setPassport("12345"); + personUpdate.setPassportExpirationDate(date); + final var groups = groupService.groupPropertiesBy(personUpdate, "ExpirationDate"); + groups.forEach((key, map) -> groupService.setProperties(person, map)); + Assertions.assertEquals("12345", person.getPassport()); + Assertions.assertEquals("FGHIJ", person.getDriverLicense()); + } +} diff --git a/source/src/test/java/com/company/architecture/invoice/InvoiceIntegrationTest.java b/source/src/test/java/com/company/architecture/invoice/InvoiceIntegrationTest.java new file mode 100644 index 0000000..4c76f1a --- /dev/null +++ b/source/src/test/java/com/company/architecture/invoice/InvoiceIntegrationTest.java @@ -0,0 +1,37 @@ +package com.company.architecture.invoice; + +import com.company.architecture.IntegrationTest; +import com.company.architecture.invoice.dtos.AddInvoiceDto; +import com.company.architecture.invoice.dtos.AddInvoiceItemDto; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class InvoiceIntegrationTest extends IntegrationTest { + static AddInvoiceDto addInvoiceDto() { + return new AddInvoiceDto( + UUID.randomUUID().toString(), + LocalDateTime.now(), + InvoiceStatus.DRAFT, + List.of(new AddInvoiceItemDto("Product", 2, BigDecimal.valueOf(250))) + ); + } + + @Test + void shouldReturnOkWhenGettingInvoices() throws Exception { + perform(POST, "/invoices", addInvoiceDto()).andExpectAll(status().isCreated()); + perform(GET, "/invoices").andExpectAll(status().isOk()); + } + + @Test + void shouldReturnCreatedWhenPostingInvoice() throws Exception { + perform(POST, "/invoices", addInvoiceDto()).andExpectAll(status().isCreated()); + } +} diff --git a/source/src/test/java/com/company/architecture/invoice/InvoiceRepositoryTest.java b/source/src/test/java/com/company/architecture/invoice/InvoiceRepositoryTest.java new file mode 100644 index 0000000..47158fc --- /dev/null +++ b/source/src/test/java/com/company/architecture/invoice/InvoiceRepositoryTest.java @@ -0,0 +1,38 @@ +package com.company.architecture.invoice; + +import com.company.architecture.PostgreTest; +import com.company.architecture.invoice.entities.Invoice; +import com.company.architecture.invoice.entities.InvoiceItem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +class InvoiceRepositoryTest extends PostgreTest { + @Autowired + InvoiceRepository invoiceRepository; + + @RepeatedTest(2) + void shouldPersistInvoiceCorrectlyWhenSaved() { + final var item = InvoiceItem.builder() + .product("Ball") + .quantity(BigDecimal.valueOf(2)) + .unitPrice(BigDecimal.valueOf(100)) + .build(); + + var invoice = Invoice.builder() + .number("123") + .dateTime(LocalDateTime.now()) + .status(InvoiceStatus.ISSUED) + .items(List.of(item)) + .build(); + + invoice = testEntityManager.persist(invoice); + + Assertions.assertEquals(1, invoiceRepository.findAll().size()); + Assertions.assertEquals(1, invoice.getId()); + } +} diff --git a/source/src/test/java/com/company/architecture/location/LocationServiceTest.java b/source/src/test/java/com/company/architecture/location/LocationServiceTest.java new file mode 100644 index 0000000..d2c956f --- /dev/null +++ b/source/src/test/java/com/company/architecture/location/LocationServiceTest.java @@ -0,0 +1,32 @@ +package com.company.architecture.location; + +import com.company.architecture.shared.services.MapperService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; + +@SpringBootTest(classes = {ObjectMapper.class, MapperService.class, LocationService.class}) +class LocationServiceTest { + @Autowired + LocationService locationService; + + @Autowired + MapperService mapperService; + + @Test + void shouldReturnCountriesWhenGettingCountriesFromLocations() throws JsonProcessingException { + final var locations = new ArrayList(); + locations.add(new FlatLocation(new Location("BR", "Brazil"), new Location("SP", "São Paulo"), new Location("SA", "Santo André"))); + locations.add(new FlatLocation(new Location("US", "United States"), new Location("CA", "California"), new Location("LA", "Los Angeles"))); + locations.add(new FlatLocation(new Location("US", "United States"), new Location("CA", "California"), new Location("SF", "San Francisco"))); + locations.add(new FlatLocation(new Location("US", "United States"), new Location("TX", "Texas"), new Location("HOU", "Houston"))); + final var countries = locationService.getCountries(locations); + Assertions.assertNotNull(countries); + System.out.printf(mapperService.toJson(countries)); + } +} diff --git a/source/src/test/java/com/company/architecture/notification/NotificationTest.java b/source/src/test/java/com/company/architecture/notification/NotificationTest.java new file mode 100644 index 0000000..f54e5a2 --- /dev/null +++ b/source/src/test/java/com/company/architecture/notification/NotificationTest.java @@ -0,0 +1,45 @@ +package com.company.architecture.notification; + +import com.company.architecture.KafkaTest; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; + +@SpringBootTest(classes = {NotificationProducer.class}) +@Import(NotificationTest.Configuration.class) +class NotificationTest extends KafkaTest { + @Autowired + NotificationProducer notificationProducer; + + @Test + void shouldNotThrowExceptionWhenProducingNotification() { + Assertions.assertDoesNotThrow(() -> notificationProducer.produce()); + } + + static class Configuration { + @Bean + public ProducerFactory notificationProducerFactory() { + final var configs = new HashMap(); + configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers()); + configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new DefaultKafkaProducerFactory<>(configs); + } + + @Bean + public KafkaTemplate notificationKafkaTemplate() { + return new KafkaTemplate<>(notificationProducerFactory()); + } + } +} diff --git a/source/src/test/java/com/company/architecture/payment/PaymentServiceTest.java b/source/src/test/java/com/company/architecture/payment/PaymentServiceTest.java new file mode 100644 index 0000000..545a964 --- /dev/null +++ b/source/src/test/java/com/company/architecture/payment/PaymentServiceTest.java @@ -0,0 +1,22 @@ +package com.company.architecture.payment; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = {PaymentService.class, CreditCardPaymentStrategy.class, DebitCardPaymentStrategy.class}) +class PaymentServiceTest { + @Autowired + PaymentService paymentService; + + @Test + void shouldReturnCreditCardPaymentStrategyWhenProcessingCreditCard() { + Assertions.assertEquals(CreditCardPaymentStrategy.class.getSimpleName(), paymentService.process(PaymentMethod.CREDIT_CARD)); + } + + @Test + void shouldReturnDebitCardPaymentStrategyWhenProcessingDebitCard() { + Assertions.assertEquals(DebitCardPaymentStrategy.class.getSimpleName(), paymentService.process(PaymentMethod.DEBIT_CARD)); + } +} diff --git a/source/src/test/java/com/company/architecture/product/ProductControllerTest.java b/source/src/test/java/com/company/architecture/product/ProductControllerTest.java new file mode 100644 index 0000000..b354730 --- /dev/null +++ b/source/src/test/java/com/company/architecture/product/ProductControllerTest.java @@ -0,0 +1,40 @@ +package com.company.architecture.product; + +import com.company.architecture.ControllerTest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.stream.Stream; + +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = ProductController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@MockBean(ProductService.class) +class ProductControllerTest extends ControllerTest { + private static Stream parameters() { + return Stream.of( + arguments("?page=-1", "[page: must be greater than or equal to 0]"), + arguments("?page=9999999999", "[page: must be valid]"), + arguments("?page=X", "[page: must be valid]"), + arguments("?size=0", "[size: must be greater than 0]"), + arguments("?size=9999999999", "[size: must be valid]"), + arguments("?size=X", "[size: must be valid]"), + arguments("?direction=X", "[direction: must be valid]"), + arguments("/1", "[id: must be valid]"), + arguments("/X", "[id: must be valid]") + ); + } + + @ParameterizedTest + @MethodSource("parameters") + void shouldReturnBadRequestWhenGettingProductsWithInvalidUri(String uri, String message) throws Exception { + perform(GET, "/products" + uri).andExpectAll(status().isBadRequest(), content().string(message)); + } +} diff --git a/source/src/test/java/com/company/architecture/product/ProductIntegrationTest.java b/source/src/test/java/com/company/architecture/product/ProductIntegrationTest.java new file mode 100644 index 0000000..b727dc4 --- /dev/null +++ b/source/src/test/java/com/company/architecture/product/ProductIntegrationTest.java @@ -0,0 +1,90 @@ +package com.company.architecture.product; + +import com.company.architecture.IntegrationTest; +import com.company.architecture.product.dtos.AddProductDto; +import com.company.architecture.product.dtos.UpdateProductDto; +import com.company.architecture.shared.Data; +import com.company.architecture.shared.dtos.PageableDto; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.springframework.http.HttpMethod.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ProductIntegrationTest extends IntegrationTest { + @ParameterizedTest + @ValueSource(strings = {"?description=inexistent", "/" + Data.ID_INEXISTENT}) + void shouldReturnNotFoundWhenGettingProductWithInexistentUri(String uri) throws Exception { + perform(GET, "/products" + uri).andExpectAll(status().isNotFound()); + } + + @ParameterizedTest + @ValueSource(strings = {"page=0", "size=1", "sort=id", "sort=description", "direction=ASC", "direction=DESC", "description=Product"}) + void shouldReturnOkWhenGettingProductsWithValidQueryParams(String uri) throws Exception { + mongoTemplate.save(Data.PRODUCT); + perform(GET, "/products?" + uri).andExpectAll( + status().isOk(), + jsonPath("$.content").isArray(), + jsonPath("$.content.length()").value(1), + jsonPath("$.content[0].id").value(Data.PRODUCT.getId().toString()), + jsonPath("$.content[0].description").value(Data.PRODUCT.getDescription()), + jsonPath("$.content[0].price").value(Data.PRODUCT.getPrice()), + jsonPath("$.page.size").value(new PageableDto().getSize()), + jsonPath("$.page.number").value(0), + jsonPath("$.page.totalElements").value(1), + jsonPath("$.page.totalPages").value(1) + ); + } + + @Test + void shouldReturnOkWhenGettingProductById() throws Exception { + mongoTemplate.save(Data.PRODUCT); + perform(GET, "/products/" + Data.PRODUCT.getId(), null).andExpectAll( + status().isOk(), + jsonPath("$.id").value(Data.PRODUCT.getId().toString()), + jsonPath("$.description").value(Data.PRODUCT.getDescription()), + jsonPath("$.price").value(Data.PRODUCT.getPrice()) + ); + } + + @Test + void shouldReturnCreatedWhenPostingProduct() throws Exception { + perform(POST, "/products", mapperService.map(Data.PRODUCT, AddProductDto.class)).andExpectAll(status().isCreated()); + } + + @Test + void shouldReturnOkWhenUpdatingProduct() throws Exception { + mongoTemplate.save(Data.PRODUCT); + perform(PUT, "/products/" + Data.PRODUCT.getId(), mapperService.map(Data.PRODUCT_UPDATE, UpdateProductDto.class)).andExpectAll(status().isOk()); + final var product = mongoTemplate.findById(Data.PRODUCT.getId(), Product.class); + Assertions.assertNotNull(product); + Assertions.assertEquals(Data.PRODUCT_UPDATE.getDescription(), product.getDescription()); + Assertions.assertEquals(Data.PRODUCT_UPDATE.getPrice(), product.getPrice()); + } + + @Test + void shouldReturnNotFoundWhenPatchingProductWithInexistentId() throws Exception { + perform(PATCH, "/products/%s/price/100".formatted(Data.ID_INEXISTENT)).andExpectAll(status().isNotFound()); + } + + @Test + void shouldReturnOkWhenPatchingProductPrice() throws Exception { + mongoTemplate.save(Data.PRODUCT); + perform(PATCH, "/products/%s/price/%s".formatted(Data.PRODUCT.getId(), Data.PRODUCT_UPDATE.getPrice()), null).andExpectAll(status().isOk()); + final var product = mongoTemplate.findById(Data.PRODUCT.getId(), Product.class); + Assertions.assertNotNull(product); + Assertions.assertEquals(Data.PRODUCT.getDescription(), product.getDescription()); + Assertions.assertEquals(Data.PRODUCT_UPDATE.getPrice(), product.getPrice()); + } + + @ParameterizedTest + @ValueSource(strings = {Data.ID, Data.ID_INEXISTENT}) + void shouldReturnOkWhenDeletingProduct(String id) throws Exception { + mongoTemplate.save(Data.PRODUCT); + perform(DELETE, "/products/" + id).andExpectAll(status().isOk()); + Assertions.assertNull(mongoTemplate.findById(id, Product.class)); + } +} diff --git a/source/src/test/java/com/company/architecture/product/ProductRepositoryTest.java b/source/src/test/java/com/company/architecture/product/ProductRepositoryTest.java new file mode 100644 index 0000000..836bdd0 --- /dev/null +++ b/source/src/test/java/com/company/architecture/product/ProductRepositoryTest.java @@ -0,0 +1,20 @@ +package com.company.architecture.product; + +import com.company.architecture.MongoTest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.util.UUID; + +class ProductRepositoryTest extends MongoTest { + @Autowired + ProductRepository productRepository; + + @Test + void shouldPersistProductCorrectlyInRepository() { + mongoTemplate.save(new Product(UUID.randomUUID(), "Ball", BigDecimal.valueOf(100))); + Assertions.assertFalse(productRepository.findAll().isEmpty()); + } +} diff --git a/source/src/test/java/com/company/architecture/shared/Color.java b/source/src/test/java/com/company/architecture/shared/Color.java new file mode 100644 index 0000000..9bf32b5 --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/Color.java @@ -0,0 +1,7 @@ +package com.company.architecture.shared; + +public enum Color { + RED, + GREEN, + BLUE +} diff --git a/source/src/test/java/com/company/architecture/shared/Data.java b/source/src/test/java/com/company/architecture/shared/Data.java new file mode 100644 index 0000000..a2c3e66 --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/Data.java @@ -0,0 +1,23 @@ +package com.company.architecture.shared; + +import com.company.architecture.auth.Authority; +import com.company.architecture.product.Product; +import com.company.architecture.user.User; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public class Data { + public static final String ID = "AA8B9189-5220-4345-9230-6301765EA5A6"; + public static final String ID_INEXISTENT = "D095635D-249C-469C-BA1E-6D78F940177C"; + public static final String PASSWORD = "123456"; + public static final String PASSWORD_ENCODED = new BCryptPasswordEncoder().encode(PASSWORD); + public static final List AUTHORITIES = List.of(Authority.ADMINISTRATOR); + public static final User USER = new User(UUID.fromString(ID), "User", "user@mail.com", "user", PASSWORD_ENCODED, AUTHORITIES); + public static final User USER_UPDATE = new User(UUID.fromString(ID), "User Update", "user.update@mail.com", "user", PASSWORD_ENCODED, AUTHORITIES); + public static final User USER_CONFLICT = new User(UUID.randomUUID(), "User Conflict", "user@mail.com", "user", PASSWORD_ENCODED, AUTHORITIES); + public static final Product PRODUCT = new Product(UUID.fromString(ID), "Product", BigDecimal.valueOf(100L)); + public static final Product PRODUCT_UPDATE = new Product(UUID.fromString(ID), "Product Update", BigDecimal.valueOf(200L)); +} diff --git a/source/src/test/java/com/company/architecture/shared/Dto.java b/source/src/test/java/com/company/architecture/shared/Dto.java new file mode 100644 index 0000000..127088f --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/Dto.java @@ -0,0 +1,8 @@ +package com.company.architecture.shared; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record Dto(UUID uuid, String string, BigDecimal bigDecimal, LocalDate date, Color color) { +} diff --git a/source/src/test/java/com/company/architecture/shared/Entity.java b/source/src/test/java/com/company/architecture/shared/Entity.java new file mode 100644 index 0000000..2582fae --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/Entity.java @@ -0,0 +1,20 @@ +package com.company.architecture.shared; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class Entity { + private UUID uuid; + private String string; + private BigDecimal bigDecimal; + private LocalDate date; + private Color color; +} diff --git a/source/src/test/java/com/company/architecture/shared/services/MapperServiceTest.java b/source/src/test/java/com/company/architecture/shared/services/MapperServiceTest.java new file mode 100644 index 0000000..0f78012 --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/services/MapperServiceTest.java @@ -0,0 +1,115 @@ +package com.company.architecture.shared.services; + +import com.company.architecture.shared.Color; +import com.company.architecture.shared.Dto; +import com.company.architecture.shared.Entity; +import com.company.architecture.shared.configurations.JacksonConfiguration; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@SpringBootTest(classes = {JacksonConfiguration.class, MapperService.class}) +class MapperServiceTest { + @Autowired + MapperService mapperService; + + @Test + void shouldReturnNullWhenMappingNullSourceToDto() { + Assertions.assertNull(mapperService.map(null, Dto.class)); + } + + @Test + void shouldReturnEqualObjectWhenMappingNonNullSourceToDto() { + final var entity = getEntity(); + final var dto = mapperService.map(entity, Dto.class); + assertEquals(entity, dto); + } + + @Test + void shouldReturnEmptyListWhenMappingNullSourceToList() { + Assertions.assertEquals(Collections.emptyList(), mapperService.mapList(null, Dto.class)); + } + + @Test + void shouldReturnEqualListWhenMappingNonNullSourceToList() { + final var entity = getEntity(); + final var dtos = mapperService.mapList(List.of(entity), Dto.class); + assertEquals(entity, dtos.getFirst()); + } + + @Test + void shouldReturnNullWhenMappingNullSourceToPage() { + Assertions.assertNull(mapperService.mapPage(null, Dto.class)); + } + + @Test + void shouldReturnEqualPageWhenMappingNonNullSourceToPage() { + final var expected = new PageImpl<>(List.of(getEntity()), PageRequest.of(0, 10), 1); + final var actual = mapperService.mapPage(expected, Dto.class); + + Assertions.assertEquals(1, expected.getContent().size()); + Assertions.assertEquals(expected.getContent().getFirst().getUuid(), actual.getContent().getFirst().uuid()); + Assertions.assertEquals(expected.getContent().getFirst().getString(), actual.getContent().getFirst().string()); + Assertions.assertEquals(expected.getContent().getFirst().getBigDecimal(), actual.getContent().getFirst().bigDecimal()); + Assertions.assertEquals(expected.getContent().getFirst().getDate(), actual.getContent().getFirst().date()); + Assertions.assertEquals(expected.getContent().getFirst().getColor(), actual.getContent().getFirst().color()); + Assertions.assertEquals(expected.getTotalElements(), actual.getTotalElements()); + Assertions.assertEquals(expected.getNumber(), actual.getNumber()); + } + + @Test + void shouldReturnNullWhenConvertingNullSourceToJson() throws JsonProcessingException { + Assertions.assertNull(mapperService.toJson(null)); + } + + @Test + void shouldReturnNotNullWhenConvertingNonNullSourceToJson() throws JsonProcessingException { + final var expected = getEntity(); + final var actual = mapperService.toJson(expected); + Assertions.assertEquals(expected.getUuid().toString(), JsonPath.read(actual, "$.uuid").toString()); + Assertions.assertEquals(expected.getString(), JsonPath.read(actual, "$.string").toString()); + Assertions.assertEquals(expected.getBigDecimal().toString(), JsonPath.read(actual, "$.bigDecimal").toString()); + Assertions.assertEquals(expected.getDate().toString(), JsonPath.read(actual, "$.date").toString()); + Assertions.assertEquals(expected.getColor().toString(), JsonPath.read(actual, "$.color").toString()); + } + + @Test + void shouldReturnNullWhenConvertingNullJsonToEntity() throws JsonProcessingException { + Assertions.assertNull(mapperService.fromJson(null, Entity.class)); + } + + @Test + void shouldReturnEqualEntityWhenConvertingNonNullJsonToEntity() throws JsonProcessingException { + final var expected = getEntity(); + final var actual = mapperService.fromJson(mapperService.toJson(expected), Entity.class); + + Assertions.assertEquals(expected.getUuid(), actual.getUuid()); + Assertions.assertEquals(expected.getString(), actual.getString()); + Assertions.assertEquals(expected.getBigDecimal(), actual.getBigDecimal()); + Assertions.assertEquals(expected.getDate(), actual.getDate()); + Assertions.assertEquals(expected.getColor(), actual.getColor()); + } + + private static Entity getEntity() { + return new Entity(UUID.randomUUID(), "Description", BigDecimal.valueOf(100), LocalDate.now(), Color.RED); + } + + private static void assertEquals(Entity entity, Dto dto) { + Assertions.assertEquals(entity.getUuid(), dto.uuid()); + Assertions.assertEquals(entity.getString(), dto.string()); + Assertions.assertEquals(entity.getBigDecimal(), dto.bigDecimal()); + Assertions.assertEquals(entity.getDate(), dto.date()); + Assertions.assertEquals(entity.getColor(), dto.color()); + } +} diff --git a/source/src/test/java/com/company/architecture/shared/services/MessageServiceTest.java b/source/src/test/java/com/company/architecture/shared/services/MessageServiceTest.java new file mode 100644 index 0000000..7df5b8e --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/services/MessageServiceTest.java @@ -0,0 +1,14 @@ +package com.company.architecture.shared.services; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.support.ResourceBundleMessageSource; + +@SpringBootTest(classes = {ResourceBundleMessageSource.class, MessageService.class}) +class MessageServiceTest { + @Test + void shouldReturnExpectedMessageWhenGettingMessageByKey() { + Assertions.assertEquals("Test", MessageService.get("Test")); + } +} diff --git a/source/src/test/java/com/company/architecture/shared/services/ValidatorServiceTest.java b/source/src/test/java/com/company/architecture/shared/services/ValidatorServiceTest.java new file mode 100644 index 0000000..0219478 --- /dev/null +++ b/source/src/test/java/com/company/architecture/shared/services/ValidatorServiceTest.java @@ -0,0 +1,133 @@ +package com.company.architecture.shared.services; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = {ValidatorService.class}) +class ValidatorServiceTest { + @Autowired + ValidatorService validatorService; + + @Test + void shouldReturnNotNullWhenValidatingNonNullInput() { + Assertions.assertNotNull(validatorService.validate("Test")); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "000.000.000-00 | false", + "012.345.678-99 | false", + "098.765.432-10 | false", + "109.876.543-21 | false", + "111.111.111-11 | false", + "111.222.333-44 | false", + "123.456.789-00 | false", + "210.987.654-32 | false", + "222.222.222-22 | false", + "222.333.444-55 | false", + "321.098.765-43 | false", + "333.333.333-33 | false", + "333.444.555-66 | false", + "432.109.876-54 | false", + "444.444.444-44 | false", + "444.555.666-77 | false", + "543.210.987-65 | false", + "555.555.555-55 | false", + "555.666.777-88 | false", + "654.321.098-76 | false", + "666.666.666-66 | false", + "666.777.888-99 | false", + "765.432.109-87 | false", + "777.777.777-77 | false", + "777.888.999-00 | false", + "876.543.210-98 | false", + "888.888.888-88 | false", + "888.999.000-11 | false", + "987.654.321-09 | false", + "999.999.999-99 | false", + "019.369.987-77 | true", + "099.994.960-83 | true", + "119.250.043-17 | true", + "123.456.789-09 | true", + "124.977.481-01 | true", + "186.204.852-53 | true", + "209.849.468-88 | true", + "252.912.234-21 | true", + "258.531.001-90 | true", + "268.366.204-16 | true", + "373.665.374-38 | true", + "375.770.937-34 | true", + "425.941.647-20 | true", + "426.239.429-86 | true", + "434.373.227-45 | true", + "445.932.090-80 | true", + "447.203.862-53 | true", + "448.704.322-00 | true", + "459.382.535-00 | true", + "460.941.584-40 | true", + "465.653.828-08 | true", + "469.728.692-85 | true", + "500.555.100-00 | true", + "525.582.629-47 | true", + "584.703.579-99 | true", + "652.190.061-77 | true", + "655.360.304-93 | true", + "668.745.850-70 | true", + "678.202.816-69 | true", + "685.632.673-45 | true", + "688.669.898-27 | true", + "702.591.498-37 | true", + "706.506.942-79 | true", + "712.257.404-01 | true", + "722.521.918-99 | true", + "727.797.500-65 | true", + "739.485.124-93 | true", + "760.695.875-02 | true", + "773.121.062-69 | true", + "786.008.411-27 | true", + "796.587.368-07 | true", + "808.841.252-89 | true", + "814.506.847-93 | true", + "850.741.787-62 | true", + "875.375.009-83 | true", + "882.226.664-10 | true", + "890.049.917-35 | true", + "924.006.403-60 | true", + "942.022.943-27 | true", + "959.596.181-76 | true" + }) + void shouldReturnCorrectValidationResultForCpf(String value, boolean valid) { + Assertions.assertEquals(valid, validatorService.validateCpf(value)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "00.111.222/0001-38 | false", + "01.234.567/0001-21 | false", + "12.345.678/0001-91 | false", + "23.456.789/0001-75 | false", + "33.444.555/0001-67 | false", + "45.678.901/0001-44 | false", + "55.666.777/0001-51 | false", + "67.890.123/0001-03 | false", + "89.012.345/0001-62 | false", + "99.000.111/0001-53 | false", + "30.054.757/0001-29 | true", + "47.643.621/0001-57 | true", + "57.863.764/0001-28 | true", + "58.524.873/0001-83 | true", + "65.844.157/0001-49 | true", + "67.072.607/0001-58 | true", + "68.283.473/0001-87 | true", + "74.730.115/0001-78 | true", + "77.516.151/0001-21 | true", + "80.373.001/0001-10 | true" + }) + void shouldReturnCorrectValidationResultForCnpj(String value, boolean valid) { + Assertions.assertEquals(valid, validatorService.validateCnpj(value)); + } +} diff --git a/source/src/test/java/com/company/architecture/user/UserControllerTest.java b/source/src/test/java/com/company/architecture/user/UserControllerTest.java new file mode 100644 index 0000000..47b9993 --- /dev/null +++ b/source/src/test/java/com/company/architecture/user/UserControllerTest.java @@ -0,0 +1,40 @@ +package com.company.architecture.user; + +import com.company.architecture.ControllerTest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.stream.Stream; + +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = UserController.class, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@MockBean(UserService.class) +class UserControllerTest extends ControllerTest { + private static Stream parameters() { + return Stream.of( + arguments("?page=-1", "[page: must be greater than or equal to 0]"), + arguments("?page=9999999999", "[page: must be valid]"), + arguments("?page=X", "[page: must be valid]"), + arguments("?size=0", "[size: must be greater than 0]"), + arguments("?size=9999999999", "[size: must be valid]"), + arguments("?size=X", "[size: must be valid]"), + arguments("?direction=X", "[direction: must be valid]"), + arguments("/1", "[id: must be valid]"), + arguments("/X", "[id: must be valid]") + ); + } + + @ParameterizedTest + @MethodSource("parameters") + void shouldReturnBadRequestWhenGettingUserWithInvalidUri(String uri, String message) throws Exception { + perform(GET, "/users" + uri).andExpectAll(status().isBadRequest(), content().string(message)); + } +} diff --git a/source/src/test/java/com/company/architecture/user/UserIntegrationTest.java b/source/src/test/java/com/company/architecture/user/UserIntegrationTest.java new file mode 100644 index 0000000..3f5769b --- /dev/null +++ b/source/src/test/java/com/company/architecture/user/UserIntegrationTest.java @@ -0,0 +1,88 @@ +package com.company.architecture.user; + +import com.company.architecture.IntegrationTest; +import com.company.architecture.shared.Data; +import com.company.architecture.shared.dtos.PageableDto; +import com.company.architecture.user.dtos.AddUserDto; +import com.company.architecture.user.dtos.UpdateUserDto; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.springframework.http.HttpMethod.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserIntegrationTest extends IntegrationTest { + @ParameterizedTest + @ValueSource(strings = {"?name=inexistent", "/" + Data.ID_INEXISTENT}) + void shouldReturnNotFoundWhenUserDoesNotExist(String uri) throws Exception { + perform(GET, "/users" + uri).andExpectAll(status().isNotFound()); + } + + @ParameterizedTest + @ValueSource(strings = {"page=0", "size=1", "sort=id", "sort=name", "direction=ASC", "direction=DESC", "name=User"}) + void shouldReturnOkWhenFetchingUserWithValidQueryParams(String uri) throws Exception { + mongoTemplate.save(Data.USER); + perform(GET, "/users?" + uri).andExpectAll( + status().isOk(), + jsonPath("$.content").isArray(), + jsonPath("$.content.length()").value(1), + jsonPath("$.content[0].id").value(Data.USER.getId().toString()), + jsonPath("$.content[0].name").value(Data.USER.getName()), + jsonPath("$.content[0].email").value(Data.USER.getEmail()), + jsonPath("$.page.size").value(new PageableDto().getSize()), + jsonPath("$.page.number").value(0), + jsonPath("$.page.totalElements").value(1), + jsonPath("$.page.totalPages").value(1) + ); + } + + @Test + void shouldReturnOkWhenFetchingUserById() throws Exception { + mongoTemplate.save(Data.USER); + perform(GET, "/users/" + Data.USER.getId()).andExpectAll( + status().isOk(), + jsonPath("$.id").value(Data.USER.getId().toString()), + jsonPath("$.name").value(Data.USER.getName()), + jsonPath("$.email").value(Data.USER.getEmail()) + ); + } + + @Test + void shouldReturnConflictWhenCreatingUserWithExistingData() throws Exception { + mongoTemplate.save(Data.USER); + perform(POST, "/users", mapperService.map(Data.USER, AddUserDto.class)).andExpectAll(status().isConflict()); + } + + @Test + void shouldReturnCreatedWhenCreatingNewUser() throws Exception { + perform(POST, "/users", mapperService.map(Data.USER, AddUserDto.class)).andExpectAll(status().isCreated()); + } + + @Test + void shouldReturnConflictWhenUpdatingUserWithConflictingData() throws Exception { + mongoTemplate.save(Data.USER); + mongoTemplate.save(Data.USER_CONFLICT); + perform(PUT, "/users/" + Data.USER.getId(), mapperService.map(Data.USER_CONFLICT, UpdateUserDto.class)).andExpectAll(status().isConflict()); + } + + @Test + void shouldReturnOkWhenUpdatingUserSuccessfully() throws Exception { + mongoTemplate.save(Data.USER); + perform(PUT, "/users/" + Data.USER.getId(), mapperService.map(Data.USER_UPDATE, UpdateUserDto.class)).andExpectAll(status().isOk()); + final var user = mongoTemplate.findById(Data.USER.getId(), User.class); + Assertions.assertNotNull(user); + Assertions.assertEquals(Data.USER_UPDATE.getName(), user.getName()); + Assertions.assertEquals(Data.USER_UPDATE.getEmail(), user.getEmail()); + } + + @ParameterizedTest + @ValueSource(strings = {Data.ID, Data.ID_INEXISTENT}) + void shouldReturnOkWhenDeletingUser(String id) throws Exception { + mongoTemplate.save(Data.USER); + perform(DELETE, "/users/" + id).andExpectAll(status().isOk()); + Assertions.assertNull(mongoTemplate.findById(id, User.class)); + } +} diff --git a/source/src/test/resources/application.yml b/source/src/test/resources/application.yml new file mode 100644 index 0000000..6c90b96 --- /dev/null +++ b/source/src/test/resources/application.yml @@ -0,0 +1,21 @@ +spring: + profiles: + active: test + jpa: + hibernate: + ddl-auto: create + open-in-view: false + properties: + hibernate: + enable_lazy_load_no_trans: true + datasource: + driver-class-name: org.postgresql.Driver + data: + mongodb: + uuid-representation: standard + cloud: + aws: + s3: + bucket: bucket + sqs: + queue: queue