diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index c416d68..38d403c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -22,16 +22,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '17' distribution: 'temurin' # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md - name: Setup Gradle uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew - name: Build with Gradle Wrapper run: ./gradlew build @@ -55,10 +58,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 21 + - name: Set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '17' distribution: 'temurin' # Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies. diff --git a/.gitignore b/.gitignore index 30e9ba9..48c20ba 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ migrations/ Challenge Backend..pdf data/ + +PR_DESCRIPTION.md + +PULL_REQUEST.md diff --git a/Dockerfile b/Dockerfile index 7a5a04a..5802079 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,40 @@ # Use a base image with a JDK (Java Development Kit) -FROM openjdk:21-jdk-slim AS build +FROM openjdk:17-jdk-slim AS build # Set the working directory in the container WORKDIR /app COPY build.gradle settings.gradle gradlew /app/ COPY gradle /app/gradle + +# Fix line ending issues with gradlew (convert CRLF to LF) +RUN apt-get update && apt-get install -y dos2unix && dos2unix ./gradlew && chmod +x ./gradlew RUN ./gradlew build || return 0 # Copy the source code of your Spring Boot application into the container COPY . /app +# Fix line endings again for the copied files +RUN find . -type f -name "*.sh" -o -name "gradlew" | xargs dos2unix +RUN chmod +x ./gradlew + # Build the Spring Boot application inside the container -RUN ./gradlew build -x test +RUN ./gradlew bootJar -x test + +# Verify the JAR file was created and find its location +RUN find /app/build -name "*.jar" | sort # Use a base image with a JRE (Java Runtime Environment) -FROM openjdk:21-jdk-slim +FROM openjdk:17-jdk-slim # Set the working directory in the container WORKDIR /app # Copy the compiled Spring Boot JAR file into the container from the build stage -COPY --from=build /app/build/libs/split-travel-0.0.1-SNAPSHOT.jar /app/split-travel-application.jar - +COPY --from=build /app/build/libs/twitter-challenge-espenia-0.0.1-SNAPSHOT.jar /app/application.jar # Expose the port your Spring Boot application is running on (default is 8080) EXPOSE 8080 # Command to run your Spring Boot application -CMD ["java", "-jar", "/app/split-travel-application.jar"] +CMD ["java", "-jar", "/app/application.jar"] diff --git a/README.md b/README.md index dccfb75..25de75e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Repositorio para el BackEnd de la plataforma challange twitter "Challange Twitte * [Integrantes](#integrantes) +* [Arquitectura](#arquitectura) + +* [Docker](#docker) + * [Dependencias](#dependencias) * [Wiki](#wiki) @@ -28,11 +32,105 @@ Repositorio para el BackEnd de la plataforma challange twitter "Challange Twitte
+# Arquitectura + +El proyecto sigue una arquitectura de Clean Architecture con separación clara de responsabilidades: + +## Capas de la Aplicación + +1. **Entrypoint (Controllers)**: Maneja las solicitudes HTTP y respuestas. + - Ubicación: `src/main/java/twitter/challenge/espenia/entrypoint/` + - Componentes: `UserController`, `TweetController` + +2. **Core (Dominio)**: Contiene la lógica de negocio y modelos de dominio. + - Ubicación: `src/main/java/twitter/challenge/espenia/core/` + - Subdirectorios principales: + - `domain`: Entidades principales (`User`, `Tweet`) + - `usecase`: Servicios y lógica de negocio + - `gateway`: Interfaces para acceso a datos + - `exception`: Excepciones de dominio + +3. **Infraestructura**: Implementa mecanismos de persistencia y servicios externos. + - Ubicación: `src/main/java/twitter/challenge/espenia/infra/` + - Componentes: + - `mongodb`: Configuración y documentos de MongoDB + - `gateway`: Implementaciones de las interfaces del gateway + +## Flujo de Datos + +1. Las peticiones HTTP llegan a los controladores en la capa Entrypoint +2. Los controladores delegan en casos de uso (Core) +3. Los casos de uso utilizan gateways para acceder a los datos +4. La capa de infraestructura implementa estos gateways para interactuar con MongoDB + +
+ +# Docker + +El proyecto incluye configuración para despliegue con Docker, facilitando su ejecución en cualquier entorno. + +## Requisitos Previos + +- Docker +- Docker Compose + +## Configuración + +### Dockerfile + +El proyecto utiliza un enfoque de construcción multi-etapa: + +1. **Etapa de construcción**: Compila la aplicación usando JDK 17 +2. **Etapa de ejecución**: Ejecuta la aplicación con una imagen liviana + +### Docker Compose + +El archivo `docker-compose.yml` configura: + +- **Servicio principal**: La aplicación Spring Boot +- **MongoDB**: Base de datos NoSQL para almacenamiento +- **Servicio de inicialización**: Configura usuarios y colecciones en MongoDB + +## Uso + +Para iniciar la aplicación con Docker: + +```bash +docker-compose up -d +``` + +Para detener la aplicación: + +```bash +docker-compose down +``` + +Para ver los logs: + +```bash +docker-compose logs -f +``` + +## Variables de Entorno + +El servicio principal utiliza las siguientes variables de entorno que pueden ser modificadas según necesidad: + +- `SCOPE_SUFFIX`: Perfil activo (por defecto: `prod`) +- `MONGODB_URI`: URI de conexión a MongoDB +- `MONGODB_DATABASE`: Nombre de la base de datos +- `SERVER_PORT`: Puerto de la aplicación (por defecto: 8090) + +
+ # Dependencias |
Dependencia
|
Versión
|
Descripción
| |:------------------------------------------------------------------|:------------------------:|:------------------------------------------------------------------------------------:| | [Spring Boot](https://spring.io/projects/spring-boot) | 3.3.5 | La librería base sobre la que se construyen interacciones con el server del BackEnd. | +| [MongoDB](https://www.mongodb.com/) | Latest | Base de datos NoSQL para almacenamiento de usuarios y tweets. | +| [MapStruct](https://mapstruct.org/) | 1.6.3 | Framework para mapeo de objetos entre capas de dominio y persistencia. | +| [Lombok](https://projectlombok.org/) | 1.18.30 | Reduce el código repetitivo mediante anotaciones. | +| [Spring Validation](https://spring.io/guides/gs/validating-form-input/) | 3.3.5 | Validación de datos en los requests. |
@@ -42,6 +140,22 @@ Si bien la documentación del [código fuente](./CONTRIBUTING.md#código-fuente) el uso de los _end points_ para interactuar con el FrontEnd viene mejor explicado en la [wiki](https://github.com/espenia/split-travel-be/wiki) del proyecto. +## API Endpoints + +### Usuarios + +- **GET /api/users/{id}**: Obtiene un usuario por ID +- **GET /api/users/username/{username}**: Obtiene un usuario por nombre de usuario +- **POST /api/users**: Crea un nuevo usuario +- **PATCH /api/users/{id}**: Actualiza un usuario existente +- **DELETE /api/users/{id}**: Elimina un usuario + +### Tweets + +- **POST /api/tweets**: Crea un nuevo tweet + +La documentación completa de la API está disponible en formato Swagger en `docs/specs/swagger.yaml`. +
# Convenciones @@ -51,3 +165,30 @@ Las convenciones utilizadas en el proyecto, como las utilizadas para el [*pull requests*](./CONTRIBUTING.md#pull-requests) o la formación de [*issues*](./CONTRIBUTING.md#issues) se encuentran en el [archivo](./CONTRIBUTING.md) correspondiente. + +
+ +# CI/CD + +El proyecto utiliza GitHub Actions para integración continua. + +## Workflow de CI + +El flujo de trabajo de CI está definido en `.github/workflows/gradle.yml` y se ejecuta automáticamente en: +- Push a la rama `main` +- Pull Requests dirigidas a `main` + +### Etapas del Pipeline + +1. **Checkout**: Obtiene el código fuente +2. **Setup JDK**: Configura Java 17 +3. **Setup Gradle**: Configura Gradle con caché para dependencias +4. **Build**: Compila y ejecuta pruebas + +### Ejecución Local + +Para ejecutar el build localmente antes de hacer push: + +```bash +./gradlew build +``` diff --git a/build.gradle b/build.gradle index 4907f77..7f7d850 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,8 @@ dependencies { implementation("com.newrelic.agent.java:newrelic-api:$newRelicVersion") implementation("commons-io:commons-io:$commonsIoVersion") implementation("jakarta.servlet:jakarta.servlet-api:$jakartaServletApiVersion") - implementation("javax.xml.bind:jaxb-api:$jaxbApiVersion") + implementation("javax.xml.bind:jaxb-api:$jaxbApiVersion") + implementation("org.hibernate.validator:hibernate-validator:8.0.0.Final") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-mongodb") implementation("org.springframework.boot:spring-boot-starter-jetty") @@ -82,3 +83,8 @@ jar { } } +bootJar { + archiveBaseName = 'twitter-challenge-espenia' + archiveVersion = '0.0.1-SNAPSHOT' +} + diff --git a/business.txt b/business.txt index e69de29..d142682 100644 --- a/business.txt +++ b/business.txt @@ -0,0 +1 @@ +to accelerate development i used a local mongo database, in memory needs to be researched \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a39ad38 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + twitter-challenge: + build: + context: . # Use the current directory as the build context + dockerfile: Dockerfile # Use the existing Dockerfile + ports: + - "8090:8090" # Map container port 8090 to host port 8090 + + environment: + - SCOPE_SUFFIX=prod # Set the active profile to local + - MONGODB_URI=mongodb://mongodb + - MONGODB_DATABASE=challenge-twitter + - MONGODB_USERNAME=user + - MONGODB_PASSWORD=user + - MONGODB_PORT=27017 + - SERVER_PORT=8090 + restart: unless-stopped + container_name: twitter-challenge-app + volumes: + - ./data:/app/data # Mount the data directory for persistence if needed + depends_on: + - mongo-init + + mongodb: + image: mongo:latest + container_name: mongodb + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db # Persist MongoDB data + environment: + - MONGO_INITDB_ROOT_USERNAME=root + - MONGO_INITDB_ROOT_PASSWORD=rootpassword + - MONGO_INITDB_DATABASE=challenge-twitter + restart: unless-stopped + command: mongod --auth + + mongo-init: + image: mongo:latest + depends_on: + - mongodb + restart: on-failure + command: > + bash -c "sleep 10 && mongosh --host mongodb -u root -p rootpassword --authenticationDatabase admin --eval ' + db = db.getSiblingDB(\"challenge-twitter\"); + db.createUser({user: \"user\", pwd: \"user\", roles: [{role: \"readWrite\", db: \"challenge-twitter\"}]}); + '" + +volumes: + mongodb_data: # Named volume for MongoDB data persistence \ No newline at end of file diff --git a/docs/specs/swagger.yaml b/docs/specs/swagger.yaml index 281aed3..fb322fc 100644 --- a/docs/specs/swagger.yaml +++ b/docs/specs/swagger.yaml @@ -10,18 +10,20 @@ "description" : "Scope local" } ], "paths" : { - "/api/users/{id}" : { - "get" : { + "/api/users" : { + "post" : { "tags" : [ "user-controller" ], - "operationId" : "getUser", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string" - } - } ], + "operationId" : "createUser", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/UserRequest" + } + } + }, + "required" : true + }, "responses" : { "200" : { "description" : "OK", @@ -34,28 +36,48 @@ } } } - }, - "put" : { - "tags" : [ "user-controller" ], - "operationId" : "updateUser", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string" - } - } ], + } + }, + "/api/tweets" : { + "post" : { + "tags" : [ "tweet-controller" ], + "operationId" : "createTweet", "requestBody" : { "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/UserUpdateRequest" + "$ref" : "#/components/schemas/TweetRequest" } } }, "required" : true }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TweetResponse" + } + } + } + } + } + } + }, + "/api/users/{id}" : { + "get" : { + "tags" : [ "user-controller" ], + "operationId" : "getUser", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string" + } + } ], "responses" : { "200" : { "description" : "OK", @@ -85,17 +107,23 @@ "description" : "OK" } } - } - }, - "/api/users" : { - "post" : { + }, + "patch" : { "tags" : [ "user-controller" ], - "operationId" : "createUser", + "operationId" : "updateUser", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string" + } + } ], "requestBody" : { "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/UserRequest" + "$ref" : "#/components/schemas/UserUpdateRequest" } } }, @@ -162,14 +190,23 @@ }, "components" : { "schemas" : { - "UserUpdateRequest" : { + "UserRequest" : { + "required" : [ "bio", "display_name", "email", "password", "username" ], "type" : "object", "properties" : { + "username" : { + "maxLength" : 50, + "minLength" : 3, + "type" : "string" + }, "display_name" : { "maxLength" : 100, "minLength" : 0, "type" : "string" }, + "email" : { + "type" : "string" + }, "password" : { "maxLength" : 100, "minLength" : 8, @@ -199,34 +236,53 @@ }, "bio" : { "type" : "string" + } + } + }, + "TweetRequest" : { + "required" : [ "content", "user_id" ], + "type" : "object", + "properties" : { + "user_id" : { + "type" : "string" + }, + "content" : { + "maxLength" : 280, + "minLength" : 1, + "type" : "string" + } + } + }, + "TweetResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "user_id" : { + "type" : "string" + }, + "content" : { + "type" : "string" }, "created_at" : { "type" : "string", "format" : "date-time" }, - "updated_at" : { - "type" : "string", - "format" : "date-time" + "like_count" : { + "type" : "integer", + "format" : "int64" } } }, - "UserRequest" : { - "required" : [ "bio", "display_name", "email", "password", "username" ], + "UserUpdateRequest" : { "type" : "object", "properties" : { - "username" : { - "maxLength" : 50, - "minLength" : 3, - "type" : "string" - }, "display_name" : { "maxLength" : 100, "minLength" : 0, "type" : "string" }, - "email" : { - "type" : "string" - }, "password" : { "maxLength" : 100, "minLength" : 8, diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..a595206 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..598cfa9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'challenge-twitter-espenia' diff --git a/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java b/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java new file mode 100644 index 0000000..95b872c --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/core/domain/Tweet.java @@ -0,0 +1,18 @@ +package twitter.challenge.espenia.core.domain; + +import lombok.*; +import java.util.Date; + +@EqualsAndHashCode(of = "id", callSuper = false) +@Getter +@ToString +@Builder +public class Tweet { + private final String id; + private final String userId; + private final String content; + private final Date createdAt; + + @Setter + private long likeCount; +} diff --git a/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java b/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java new file mode 100644 index 0000000..c8a63c0 --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/core/gateway/TweetGateway.java @@ -0,0 +1,7 @@ +package twitter.challenge.espenia.core.gateway; + +import twitter.challenge.espenia.core.domain.Tweet; + +public interface TweetGateway { + Tweet create(Tweet tweet); +} diff --git a/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java b/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java new file mode 100644 index 0000000..ebe543b --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/core/result/TweetResponse.java @@ -0,0 +1,13 @@ +package twitter.challenge.espenia.core.result; + +import lombok.Builder; +import java.util.Date; + +@Builder +public record TweetResponse( + String id, + String userId, + String content, + Date createdAt, + Long likeCount +) {} diff --git a/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java b/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java new file mode 100644 index 0000000..922f898 --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/core/usecase/CreateTweet.java @@ -0,0 +1,42 @@ +package twitter.challenge.espenia.core.usecase; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.core.gateway.TweetGateway; +import twitter.challenge.espenia.core.gateway.UserGateway; +import twitter.challenge.espenia.core.result.TweetResponse; +import twitter.challenge.espenia.core.usecase.request.TweetRequest; + +@Service +@RequiredArgsConstructor +public class CreateTweet { + + private final TweetGateway tweetGateway; + private final UserGateway userGateway; + + @Transactional + public TweetResponse execute(final TweetRequest tweetRequest) { + // Verify user exists + userGateway.findById(tweetRequest.userId()); + + // Create tweet + final Tweet tweet = Tweet.builder() + .userId(tweetRequest.userId()) + .content(tweetRequest.content()) + .build(); + + return mapToTweetResponse(tweetGateway.create(tweet)); + } + + private TweetResponse mapToTweetResponse(final Tweet tweet) { + return TweetResponse.builder() + .id(tweet.getId()) + .userId(tweet.getUserId()) + .content(tweet.getContent()) + .createdAt(tweet.getCreatedAt()) + .likeCount(tweet.getLikeCount()) + .build(); + } +} diff --git a/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java b/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java new file mode 100644 index 0000000..5cb10ae --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/core/usecase/request/TweetRequest.java @@ -0,0 +1,13 @@ +package twitter.challenge.espenia.core.usecase.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TweetRequest( + @NotBlank(message = "User ID is required") + String userId, + + @NotBlank(message = "Content is required") + @Size(max = 280, message = "Tweet content must be between 1 and 280 characters") + String content +) {} diff --git a/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java b/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java new file mode 100644 index 0000000..85c6809 --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/entrypoint/TweetController.java @@ -0,0 +1,35 @@ +package twitter.challenge.espenia.entrypoint; + +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import twitter.challenge.espenia.core.usecase.CreateTweet; +import twitter.challenge.espenia.core.usecase.request.TweetRequest; +import twitter.challenge.espenia.core.result.TweetResponse; + +@RestController +@RequestMapping("/api/tweets") +public class TweetController { + + private final CreateTweet createTweet; + + @Autowired + public TweetController(final CreateTweet createTweet) { + this.createTweet = createTweet; + } + + /** + * Create a new tweet + * + * @param tweetRequest The tweet data + * @return The created tweet + */ + @PostMapping + public ResponseEntity createTweet(@Valid @RequestBody final TweetRequest tweetRequest) { + TweetResponse createdTweet = createTweet.execute(tweetRequest); + return new ResponseEntity<>(createdTweet, HttpStatus.CREATED); + } +} diff --git a/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java b/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java index d586cd3..ee7bc46 100644 --- a/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java +++ b/src/main/java/twitter/challenge/espenia/entrypoint/handler/ControllerExceptionHandler.java @@ -1,6 +1,7 @@ package twitter.challenge.espenia.entrypoint.handler; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestValueException; import twitter.challenge.espenia.core.exception.BaseAPIException; import twitter.challenge.espenia.entrypoint.exception.response.dto.ApiError; @@ -121,4 +122,39 @@ protected ResponseEntity handleUnknownException(final Exception e) { HttpStatus.INTERNAL_SERVER_ERROR.value()); return ResponseEntity.status(apiError.getStatus()).body(apiError); } + + /** + * Handler for validation errors. + * + * @param ex the exception thrown when validation fails. + * @return {@link ResponseEntity} with 422 status code and the validation error message. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(final MethodArgumentNotValidException ex) { + String fieldName = ""; + String errorMessage = ""; + + if (ex.getBindingResult().getFieldError() != null) { + fieldName = ex.getBindingResult().getFieldError().getField(); + errorMessage = ex.getBindingResult().getFieldError().getDefaultMessage(); + } + + final Set validation = new HashSet<>(); + validation.add(new UnproccesableEntityApiError.Validation(fieldName, errorMessage)); + + UnproccesableEntityApiError apiError = new UnproccesableEntityApiError("validation_error", validation); + + log.info( + LOG_FLAGS, + apiError.getMessage(), + apiError.getError(), + apiError.getStatus()); + + ApiError simpleError = new ApiError( + "validation_error", + errorMessage, + HttpStatus.UNPROCESSABLE_ENTITY.value()); + + return ResponseEntity.status(apiError.getStatus()).body(simpleError); + } } diff --git a/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java b/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java new file mode 100644 index 0000000..bc9fe0c --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImpl.java @@ -0,0 +1,24 @@ +package twitter.challenge.espenia.infra.gateway; + +import org.springframework.stereotype.Component; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.core.gateway.TweetGateway; +import twitter.challenge.espenia.infra.gateway.mapper.TweetMapper; +import twitter.challenge.espenia.infra.mongodb.document.TweetDocument; +import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository; + +@Component +public class TweetGatewayImpl extends BaseMongoGateway implements TweetGateway { + + private final TweetMapper tweetMapper; + + public TweetGatewayImpl(final TweetRepository repository, final TweetMapper tweetMapper) { + super(repository); + this.tweetMapper = tweetMapper; + } + + @Override + public Tweet create(Tweet tweet) { + return tweetMapper.toDomain(repository.save(tweetMapper.toEntity(tweet))); + } +} diff --git a/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java b/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java new file mode 100644 index 0000000..45be138 --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/infra/gateway/mapper/TweetMapper.java @@ -0,0 +1,9 @@ +package twitter.challenge.espenia.infra.gateway.mapper; + +import org.mapstruct.Mapper; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.infra.mongodb.document.TweetDocument; + +@Mapper(componentModel = "spring") +public interface TweetMapper extends BaseMapper { +} diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java index d1af9ee..d0ace27 100644 --- a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java +++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/BaseDocument.java @@ -1,27 +1,27 @@ package twitter.challenge.espenia.infra.mongodb.document; -import java.util.Date; import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.domain.Persistable; -import lombok.Getter; -import lombok.Setter; import lombok.experimental.SuperBuilder; -@Getter -@Setter +import java.util.Date; + @SuperBuilder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public abstract class BaseDocument implements Persistable { - - @CreatedDate - private Date createdAt; - + public abstract String getId(); + @LastModifiedDate private Date updatedAt; + + @Override + public boolean isNew() { + return getId() == null || getId().isEmpty(); + } + } diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java new file mode 100644 index 0000000..b82cfc1 --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/TweetDocument.java @@ -0,0 +1,39 @@ +package twitter.challenge.espenia.infra.mongodb.document; + +import jakarta.validation.constraints.Min; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import lombok.experimental.SuperBuilder; + +import java.util.Date; + +@EqualsAndHashCode(of = "id", callSuper = false) +@Getter +@Setter +@SuperBuilder(toBuilder = true) +@Document(collection = "tweets") +@NoArgsConstructor +@AllArgsConstructor +public class TweetDocument extends BaseDocument { + + @Id + private String id; + + @Indexed + private String userId; + + private String content; + + @Indexed + @CreatedDate + private Date createdAt; + + @Min(0) + private long likeCount; + +} diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java index 147f595..06d6c55 100644 --- a/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java +++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/document/UserDocument.java @@ -1,7 +1,12 @@ package twitter.challenge.espenia.infra.mongodb.document; import lombok.*; + +import java.util.Date; + +import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @@ -31,8 +36,7 @@ public class UserDocument extends BaseDocument { private String bio; - @Override - public boolean isNew() { - return id == null || id.isEmpty(); - } + @CreatedDate + private Date createdAt; + } diff --git a/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java b/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java new file mode 100644 index 0000000..cabbd6d --- /dev/null +++ b/src/main/java/twitter/challenge/espenia/infra/mongodb/repository/TweetRepository.java @@ -0,0 +1,7 @@ +package twitter.challenge.espenia.infra.mongodb.repository; + +import org.springframework.data.mongodb.repository.MongoRepository; +import twitter.challenge.espenia.infra.mongodb.document.TweetDocument; + +public interface TweetRepository extends MongoRepository { +} diff --git a/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java b/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java new file mode 100644 index 0000000..12d1440 --- /dev/null +++ b/src/test/java/twitter/challenge/espenia/core/usecase/CreateTweetTest.java @@ -0,0 +1,62 @@ +package twitter.challenge.espenia.core.usecase; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.core.exception.NotFoundException; +import twitter.challenge.espenia.core.gateway.TweetGateway; +import twitter.challenge.espenia.core.gateway.UserGateway; +import twitter.challenge.espenia.core.result.TweetResponse; +import twitter.challenge.espenia.core.usecase.request.TweetRequest; +import twitter.challenge.espenia.util.Factory; +import twitter.challenge.espenia.util.UnitTest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class CreateTweetTest extends UnitTest { + @InjectMocks + private CreateTweet tweetService; + + @Mock + private TweetGateway tweetGateway; + + @Mock + private UserGateway userGateway; + + + @Test + void testCreateTweet() { + // Given + TweetRequest tweetRequest = Factory.sampleTweetCreateRequest(); + when(userGateway.findById(Factory.USERID)).thenReturn(Factory.sampleUser()); + when(tweetGateway.create(any(Tweet.class))).thenReturn(Factory.sampleTweet()); + + // When + TweetResponse response = tweetService.execute(tweetRequest); + + // Then + assertNotNull(response); + assertEquals(Factory.TWEET_ID, response.id()); + assertEquals(Factory.USERID, response.userId()); + assertEquals(Factory.TWEET_CONTENT, response.content()); + assertEquals(0, response.likeCount()); + + verify(userGateway).findById(Factory.USERID); + verify(tweetGateway).create(any(Tweet.class)); + } + + @Test + void testCreateTweetWithInvalidUser() { + // Given + TweetRequest tweetRequest = Factory.sampleTweetCreateRequest(); + when(userGateway.findById(Factory.USERID)).thenThrow(new NotFoundException("User not found")); + + // When & Then + assertThrows(NotFoundException.class, () -> tweetService.execute(tweetRequest)); + verify(userGateway).findById(Factory.USERID); + verify(tweetGateway, never()).create(any(Tweet.class)); + } +} diff --git a/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java b/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java index 9608402..801bfcb 100644 --- a/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java +++ b/src/test/java/twitter/challenge/espenia/entrypoint/BaseControllerTest.java @@ -2,13 +2,18 @@ import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; import twitter.challenge.espenia.infra.config.ObjectMapperConfig; +import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository; import twitter.challenge.espenia.infra.mongodb.repository.UserRepository; import twitter.challenge.espenia.integration.ControllerTest; import twitter.challenge.espenia.util.Factory; public abstract class BaseControllerTest extends ControllerTest { protected final UserRepository userRepository; + + @Autowired + protected TweetRepository tweetRepository; public BaseControllerTest(final UserRepository userRepository) { super(ObjectMapperConfig.getDefaultObjectMapper()); @@ -17,6 +22,7 @@ public BaseControllerTest(final UserRepository userRepository) { @BeforeEach public void setUp() { + tweetRepository.deleteAll(); userRepository.deleteAll(); } diff --git a/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java b/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java new file mode 100644 index 0000000..4c5d952 --- /dev/null +++ b/src/test/java/twitter/challenge/espenia/entrypoint/TweetControllerTest.java @@ -0,0 +1,69 @@ +package twitter.challenge.espenia.entrypoint; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import twitter.challenge.espenia.core.result.TweetResponse; +import twitter.challenge.espenia.core.usecase.request.TweetRequest; +import twitter.challenge.espenia.entrypoint.exception.response.dto.ApiError; +import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository; +import twitter.challenge.espenia.infra.mongodb.repository.UserRepository; +import twitter.challenge.espenia.util.Factory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TweetControllerTest extends BaseControllerTest { + + @Autowired + public TweetControllerTest(final UserRepository userRepository) { + super(userRepository); + } + + @Test + void testCreateTweet() { + // Setup user first since tweet requires valid user + basicEntitiesSetup(); + + // Create a tweet request + HttpEntity requestEntity = + new HttpEntity<>(Factory.sampleTweetCreateRequest(), this.getDefaultHeaders()); + + // Make the request + ResponseEntity responseEntity = + this.testRestTemplate.exchange( + "/api/tweets", + HttpMethod.POST, requestEntity, TweetResponse.class); + + // Assert the response + assertEquals(HttpStatus.CREATED, responseEntity.getStatusCode()); + assertNotNull(responseEntity.getBody()); + asserEqualsButId(responseEntity.getBody(), Factory.sampleTweetResponse()); + } + + @Test + void testCreateTweetWithTooLongContent() { + // Setup user first + basicEntitiesSetup(); + + // Create a tweet with content that exceeds 280 characters + String longContent = "a".repeat(281); + TweetRequest invalidRequest = new TweetRequest(Factory.USERID, longContent); + + HttpEntity requestEntity = + new HttpEntity<>(invalidRequest, this.getDefaultHeaders()); + + // Make the request + ResponseEntity responseEntity = + this.testRestTemplate.exchange( + "/api/tweets", + HttpMethod.POST, requestEntity, ApiError.class); + + // Assert that the request is rejected with a bad request status + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, responseEntity.getStatusCode()); + assertEquals("Tweet content must be between 1 and 280 characters", responseEntity.getBody().getMessage()); + } +} diff --git a/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java b/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java new file mode 100644 index 0000000..3790095 --- /dev/null +++ b/src/test/java/twitter/challenge/espenia/infra/gateway/TweetGatewayImplTest.java @@ -0,0 +1,54 @@ +package twitter.challenge.espenia.infra.gateway; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.infra.mongodb.repository.TweetRepository; +import twitter.challenge.espenia.infra.mongodb.repository.UserRepository; +import twitter.challenge.espenia.util.Factory; +import twitter.challenge.espenia.util.UnitTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TweetGatewayImplTest extends UnitTest { + @Autowired + private TweetGatewayImpl tweetGateway; + + @Autowired + private TweetRepository tweetRepository; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + tweetRepository.deleteAll(); + userRepository.deleteAll(); + + // Create user first since we need a valid userId + userRepository.save(Factory.sampleUserDocument()); + tweetRepository.save(Factory.sampleTweetDocument()); + } + + @Test + void testCreateTweet() { + // Given + Tweet newTweet = Tweet.builder() + .userId(Factory.USERID) + .content("This is a new test tweet") + .likeCount(0) + .build(); + + // When + Tweet createdTweet = tweetGateway.create(newTweet); + + // Then + assertNotNull(createdTweet); + assertNotNull(createdTweet.getId()); + assertEquals(Factory.USERID, createdTweet.getUserId()); + assertEquals("This is a new test tweet", createdTweet.getContent()); + assertEquals(0, createdTweet.getLikeCount()); + } +} diff --git a/src/test/java/twitter/challenge/espenia/util/Factory.java b/src/test/java/twitter/challenge/espenia/util/Factory.java index a611529..04cb8eb 100644 --- a/src/test/java/twitter/challenge/espenia/util/Factory.java +++ b/src/test/java/twitter/challenge/espenia/util/Factory.java @@ -6,6 +6,10 @@ import twitter.challenge.espenia.core.usecase.request.UserRequest; import twitter.challenge.espenia.core.usecase.request.UserUpdateRequest; import twitter.challenge.espenia.infra.mongodb.document.UserDocument; +import twitter.challenge.espenia.infra.mongodb.document.TweetDocument; +import twitter.challenge.espenia.core.domain.Tweet; +import twitter.challenge.espenia.core.usecase.request.TweetRequest; +import twitter.challenge.espenia.core.result.TweetResponse; import java.time.ZonedDateTime; import java.util.Date; @@ -15,6 +19,8 @@ public final class Factory { public static final String USERNAME = "espenia"; public static final String USERID = "12345"; public static final String EMAIL = "tatas323@gmail.com"; + public static final String TWEET_ID = "67890"; + public static final String TWEET_CONTENT = "This is a test tweet"; public static User sampleUser() { return User.builder() @@ -89,4 +95,38 @@ public static UserResponse sampleUserUpdatedResponse() { .bio("This is my new bio") .build(); } + + public static Tweet sampleTweet() { + return Tweet.builder() + .id(TWEET_ID) + .userId(USERID) + .content(TWEET_CONTENT) + .createdAt(new Date()) + .likeCount(0) + .build(); + } + + public static TweetDocument sampleTweetDocument() { + return TweetDocument.builder() + .id(TWEET_ID) + .userId(USERID) + .content(TWEET_CONTENT) + .createdAt(new Date()) + .likeCount(0) + .build(); + } + + public static TweetRequest sampleTweetCreateRequest() { + return new TweetRequest(USERID, TWEET_CONTENT); + } + + public static TweetResponse sampleTweetResponse() { + return TweetResponse.builder() + .id(TWEET_ID) + .userId(USERID) + .content(TWEET_CONTENT) + .createdAt(new Date()) + .likeCount(0L) + .build(); + } }