diff --git a/Dockerfile.native b/Dockerfile.native index ef68ae2..4c59f13 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -16,7 +16,7 @@ COPY src ./src # Build native binary # quarkus defaults to prod -RUN ./mvnw package -Dnative "-Dquarkus.native.additional-build-args=-J-Djava.net.preferIPv4Stack=true" +RUN ./mvnw clean package -DskipTests -Dnative "-Dquarkus.native.additional-build-args=-J-Djava.net.preferIPv4Stack=true" # ---------- Stage 2: Minimal Runtime ---------- diff --git a/README.md b/README.md index 00c836b..d6bc772 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ API service for the PaperTrail Bot, built with Quarkus 3 and optimized for nativ ### Services Required -| Service | Version | -|-----------------------|----------------| -| `Relational Database` | Postgres | -| `Distributed Cache` | Redis / Valkey | +| Service Type | Supported Variants | +|-----------------------|--------------------| +| `Relational Database` | Postgres | +| `Distributed Cache` | Redis / Valkey | It is recommended that you deploy the latest or the officially supported versions of Postgres and Redis or Valkey. At the time of development, Postgres 17 and 18 and Valkey 8 were fully supported. @@ -35,25 +35,6 @@ Below are the links to the official docs stating the support status of each of t - [Redis Supported Versions](https://redis.io/docs/latest/operate/rs/references/supported-platforms/) - [Valkey Supported Versions](https://valkey.io/topics/releases/) -> [!IMPORTANT] -> This section applies only to users migrating from the Spring-based API to this API. -> -> Depending on your existing database setup, you may encounter up to **two** breaking changes: -> -> **Case-1**: Using a database other than PostgreSQL -> -> You need to migrate your existing data to a newly created Postgres DB. -> This API exclusively supports Postgres. Support for other DBs have been dropped to ease maintainability. -> -> **Case-2**: Already using Postgres -> -> There is only **one** breaking change: -> - Previously, tables were created in the default schema. -> - The new API uses flyway to check and create tables in a custom schema named `papertrailbot` on startup. -> -> The table structures and relationships remain unchanged. -> You only need to migrate your existing data from the default schema to the `papertrailbot` schema. - ### Environment Variables Required | Variable | Description | @@ -156,6 +137,28 @@ Some cloud platforms may require these endpoints to periodically determine the h > /q/health - Accumulates all health check procedures in the application. +# Migration Guide + +> [!NOTE] +> This section applies only to users migrating from the Spring-based API. + +Depending on your existing database setup, you may encounter up to **two** breaking changes: + +**Case-1**: Using a database other than PostgreSQL + +You need to migrate your existing data to a newly created Postgres DB. +This API exclusively supports Postgres. Support for other DBs have been dropped to ease maintainability. + +**Case-2**: Already using Postgres + +There is only **one** breaking change: + +- Previously, tables were created in the default schema. +- The new API uses flyway to check and create tables in a custom schema named `papertrailbot` on startup. + +The table structures and relationships remain unchanged. +You only need to migrate your existing data from the default schema to the `papertrailbot` schema. + # License This API is licensed under the [AGPLv3](/LICENSE) license. diff --git a/pom.xml b/pom.xml index 9ad7531..c3c60ac 100644 --- a/pom.xml +++ b/pom.xml @@ -4,18 +4,17 @@ io.github.eggy03 papertrail-api-quarkus - 1.0.0-SNAPSHOT + 1.0.0 quarkus - 25 UTF-8 UTF-8 quarkus-bom io.quarkus.platform - 3.31.3 + 3.32.1 true 3.14.1 @@ -25,6 +24,7 @@ 3.20.0 1.18.42 4.2.0 + 3.27.7 @@ -68,10 +68,6 @@ io.quarkus quarkus-hibernate-orm-panache - - io.quarkus - quarkus-jdbc-h2 - io.quarkus quarkus-jdbc-postgresql @@ -141,11 +137,22 @@ quarkus-junit test + + io.quarkus + quarkus-junit-mockito + test + io.rest-assured rest-assured test + + org.assertj + assertj-core + ${assertj.core.version} + test + diff --git a/src/main/java/io/github/eggy03/papertrail/api/controller/MessageLogContentController.java b/src/main/java/io/github/eggy03/papertrail/api/controller/MessageLogContentController.java index 004e467..0f1331d 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/controller/MessageLogContentController.java +++ b/src/main/java/io/github/eggy03/papertrail/api/controller/MessageLogContentController.java @@ -1,7 +1,7 @@ package io.github.eggy03.papertrail.api.controller; import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; -import io.github.eggy03.papertrail.api.service.locks.MessageLogContentLockingService; +import io.github.eggy03.papertrail.api.service.locks.MessageLogContentOperation; import io.smallrye.common.annotation.RunOnVirtualThread; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -25,7 +25,7 @@ @RequiredArgsConstructor public class MessageLogContentController { - private final MessageLogContentLockingService service; // accessing the MessageLogContentService behind Redisson locks + private final MessageLogContentOperation service; @POST public Response saveMessage(@Valid MessageLogContentDTO dto) { @@ -46,7 +46,7 @@ public Response getMessage(@PathParam("messageId") @Positive @NotNull Long messa @PUT public Response updateMessage(@Valid MessageLogContentDTO dto) { return Response - .ok(service.updateMessage(dto)) + .ok(service.updateMessage(dto.getMessageId(), dto)) .build(); } diff --git a/src/main/java/io/github/eggy03/papertrail/api/dto/AuditLogRegistrationDTO.java b/src/main/java/io/github/eggy03/papertrail/api/dto/AuditLogRegistrationDTO.java index a4adf84..fbad075 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/dto/AuditLogRegistrationDTO.java +++ b/src/main/java/io/github/eggy03/papertrail/api/dto/AuditLogRegistrationDTO.java @@ -2,9 +2,13 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@NoArgsConstructor +@AllArgsConstructor public class AuditLogRegistrationDTO { @NotNull(message = "GuildID cannot be null") diff --git a/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogContentDTO.java b/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogContentDTO.java index 78ebdc5..ddf4485 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogContentDTO.java +++ b/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogContentDTO.java @@ -3,10 +3,13 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data - +@NoArgsConstructor +@AllArgsConstructor public class MessageLogContentDTO { @NotNull(message = "MessageID cannot be null") diff --git a/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogRegistrationDTO.java b/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogRegistrationDTO.java index 60c9651..504d679 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogRegistrationDTO.java +++ b/src/main/java/io/github/eggy03/papertrail/api/dto/MessageLogRegistrationDTO.java @@ -2,9 +2,13 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@NoArgsConstructor +@AllArgsConstructor public class MessageLogRegistrationDTO { @NotNull(message = "GuildID cannot be null") diff --git a/src/main/java/io/github/eggy03/papertrail/api/entity/AuditLogRegistration.java b/src/main/java/io/github/eggy03/papertrail/api/entity/AuditLogRegistration.java index 1ae9c3e..c7d1ede 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/entity/AuditLogRegistration.java +++ b/src/main/java/io/github/eggy03/papertrail/api/entity/AuditLogRegistration.java @@ -4,8 +4,9 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.RequiredArgsConstructor; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; @@ -13,7 +14,8 @@ @Getter @Setter @ToString -@RequiredArgsConstructor +@NoArgsConstructor +@AllArgsConstructor @Table(name = "audit_log_table") public class AuditLogRegistration { diff --git a/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogContent.java b/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogContent.java index ff5229b..222a93d 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogContent.java +++ b/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogContent.java @@ -4,8 +4,9 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.RequiredArgsConstructor; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; import org.hibernate.annotations.CreationTimestamp; @@ -16,7 +17,8 @@ @Getter @Setter @ToString -@RequiredArgsConstructor +@NoArgsConstructor +@AllArgsConstructor @Table(name = "message_log_content_table") public class MessageLogContent { diff --git a/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogRegistration.java b/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogRegistration.java index 56ddfb5..690e314 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogRegistration.java +++ b/src/main/java/io/github/eggy03/papertrail/api/entity/MessageLogRegistration.java @@ -4,8 +4,9 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.RequiredArgsConstructor; +import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; @@ -13,7 +14,8 @@ @Getter @Setter @ToString -@RequiredArgsConstructor +@NoArgsConstructor +@AllArgsConstructor @Table(name = "message_log_registration_table") public class MessageLogRegistration { diff --git a/src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageContentException.java b/src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationFailureException.java similarity index 62% rename from src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageContentException.java rename to src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationFailureException.java index 104f763..f3af97c 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageContentException.java +++ b/src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationFailureException.java @@ -3,5 +3,5 @@ import lombok.experimental.StandardException; @StandardException -public class MessageContentException extends RuntimeException { +public class GuildRegistrationFailureException extends RuntimeException { } diff --git a/src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationException.java b/src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageSaveFailureException.java similarity index 64% rename from src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationException.java rename to src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageSaveFailureException.java index 778851b..d78c27b 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/exceptions/GuildRegistrationException.java +++ b/src/main/java/io/github/eggy03/papertrail/api/exceptions/MessageSaveFailureException.java @@ -3,5 +3,5 @@ import lombok.experimental.StandardException; @StandardException -public class GuildRegistrationException extends RuntimeException { +public class MessageSaveFailureException extends RuntimeException { } diff --git a/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationExceptionMapper.java b/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationFailureExceptionMapper.java similarity index 83% rename from src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationExceptionMapper.java rename to src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationFailureExceptionMapper.java index 762b662..c446508 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationExceptionMapper.java +++ b/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/GuildRegistrationFailureExceptionMapper.java @@ -1,6 +1,6 @@ package io.github.eggy03.papertrail.api.exceptions.mapper; -import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationException; +import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationFailureException; import io.github.eggy03.papertrail.api.exceptions.entity.ErrorResponse; import io.github.eggy03.papertrail.api.util.AnsiColor; import jakarta.ws.rs.core.Context; @@ -14,13 +14,13 @@ @Provider @Slf4j -public class GuildRegistrationExceptionMapper implements ExceptionMapper { +public class GuildRegistrationFailureExceptionMapper implements ExceptionMapper { @Context UriInfo uriInfo; @Override - public Response toResponse(GuildRegistrationException e) { + public Response toResponse(GuildRegistrationFailureException e) { log.debug(AnsiColor.MAGENTA + "{}" + AnsiColor.RESET, e.getMessage(), e); diff --git a/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageContentExceptionMapper.java b/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageSaveFailureExceptionMapper.java similarity index 80% rename from src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageContentExceptionMapper.java rename to src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageSaveFailureExceptionMapper.java index 865d2b8..cea19e1 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageContentExceptionMapper.java +++ b/src/main/java/io/github/eggy03/papertrail/api/exceptions/mapper/MessageSaveFailureExceptionMapper.java @@ -1,6 +1,6 @@ package io.github.eggy03.papertrail.api.exceptions.mapper; -import io.github.eggy03.papertrail.api.exceptions.MessageContentException; +import io.github.eggy03.papertrail.api.exceptions.MessageSaveFailureException; import io.github.eggy03.papertrail.api.exceptions.entity.ErrorResponse; import io.github.eggy03.papertrail.api.util.AnsiColor; import jakarta.ws.rs.core.Context; @@ -14,13 +14,13 @@ @Provider @Slf4j -public class MessageContentExceptionMapper implements ExceptionMapper { +public class MessageSaveFailureExceptionMapper implements ExceptionMapper { @Context UriInfo uriInfo; @Override - public Response toResponse(MessageContentException e) { + public Response toResponse(MessageSaveFailureException e) { log.debug(AnsiColor.MAGENTA + "{}" + AnsiColor.RESET, e.getMessage(), e); diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/AuditLogRegistrationService.java b/src/main/java/io/github/eggy03/papertrail/api/service/AuditLogRegistrationService.java index b34898e..61e8b1f 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/service/AuditLogRegistrationService.java +++ b/src/main/java/io/github/eggy03/papertrail/api/service/AuditLogRegistrationService.java @@ -3,22 +3,21 @@ import io.github.eggy03.papertrail.api.dto.AuditLogRegistrationDTO; import io.github.eggy03.papertrail.api.entity.AuditLogRegistration; import io.github.eggy03.papertrail.api.exceptions.GuildNotFoundException; -import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationException; +import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationFailureException; import io.github.eggy03.papertrail.api.mapper.AuditLogRegistrationMapper; import io.github.eggy03.papertrail.api.repository.AuditLogRegistrationRepository; import io.github.eggy03.papertrail.api.util.AnsiColor; import io.quarkus.cache.CacheInvalidate; import io.quarkus.cache.CacheKey; import io.quarkus.cache.CacheResult; +import io.smallrye.common.constraint.NotNull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; -import jakarta.validation.constraints.NotNull; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; - @ApplicationScoped @RequiredArgsConstructor @Slf4j @@ -35,7 +34,7 @@ public class AuditLogRegistrationService { log.debug("{}Saved audit log guild with ID={}{}", AnsiColor.GREEN, dto.getGuildId(), AnsiColor.RESET); return dto; } catch (ConstraintViolationException e) { // from hibernate - throw new GuildRegistrationException(e); + throw new GuildRegistrationFailureException(e); } } @@ -69,13 +68,9 @@ public class AuditLogRegistrationService { @CacheInvalidate(cacheName = "auditLog") public void deleteRegisteredGuild(@NonNull @CacheKey Long guildId) { - repository - .findByIdOptional(guildId) - .orElseThrow(() -> new GuildNotFoundException("Guild is not registered for audit logging")); - if (repository.deleteById(guildId)) log.debug("{}Deleted audit log guild with ID={}{}", AnsiColor.GREEN, guildId, AnsiColor.RESET); else - log.warn("{}Failed to delete audit log guild with ID={}{}", AnsiColor.YELLOW, guildId, AnsiColor.RESET); + throw new GuildNotFoundException("Guild is not registered for audit logging"); } } diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogContentService.java b/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogContentService.java index e0caa9a..f907821 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogContentService.java +++ b/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogContentService.java @@ -2,8 +2,8 @@ import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; import io.github.eggy03.papertrail.api.entity.MessageLogContent; -import io.github.eggy03.papertrail.api.exceptions.MessageContentException; import io.github.eggy03.papertrail.api.exceptions.MessageNotFoundException; +import io.github.eggy03.papertrail.api.exceptions.MessageSaveFailureException; import io.github.eggy03.papertrail.api.mapper.MessageLogContentMapper; import io.github.eggy03.papertrail.api.repository.MessageLogContentRepository; import io.github.eggy03.papertrail.api.util.AnsiColor; @@ -11,9 +11,9 @@ import io.quarkus.cache.CacheKey; import io.quarkus.cache.CacheResult; import io.quarkus.scheduler.Scheduled; +import io.smallrye.common.constraint.NotNull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; -import jakarta.validation.constraints.NotNull; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,7 +38,7 @@ public class MessageLogContentService { log.debug("{}Saved message with ID={}{}", AnsiColor.GREEN, dto.getMessageId(), AnsiColor.RESET); return dto; } catch (ConstraintViolationException e) {// from hibernate - throw new MessageContentException(e); + throw new MessageSaveFailureException(e); } // API Note: While ConstraintViolationException covers for a lot of constraints other than PK constraint // We have already covered them during dto validation phase in the controller @@ -77,14 +77,10 @@ public class MessageLogContentService { @CacheInvalidate(cacheName = "messageContent") public void deleteMessage(@NonNull @CacheKey Long messageId) { - repository - .findByIdOptional(messageId) - .orElseThrow(() -> new MessageNotFoundException("Message to be deleted was never saved")); - if (repository.deleteById(messageId)) log.debug("{} Deleted message having ID={}{}", AnsiColor.GREEN, messageId, AnsiColor.RESET); else - log.warn("{}Failed to delete message having ID={}{}", AnsiColor.YELLOW, messageId, AnsiColor.RESET); + throw new MessageNotFoundException("Message to be deleted was never saved"); } @Scheduled(every = "24h") diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogRegistrationService.java b/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogRegistrationService.java index 18e8ed2..7d68b7c 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogRegistrationService.java +++ b/src/main/java/io/github/eggy03/papertrail/api/service/MessageLogRegistrationService.java @@ -3,22 +3,21 @@ import io.github.eggy03.papertrail.api.dto.MessageLogRegistrationDTO; import io.github.eggy03.papertrail.api.entity.MessageLogRegistration; import io.github.eggy03.papertrail.api.exceptions.GuildNotFoundException; -import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationException; +import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationFailureException; import io.github.eggy03.papertrail.api.mapper.MessageLogRegistrationMapper; import io.github.eggy03.papertrail.api.repository.MessageLogRegistrationRepository; import io.github.eggy03.papertrail.api.util.AnsiColor; import io.quarkus.cache.CacheInvalidate; import io.quarkus.cache.CacheKey; import io.quarkus.cache.CacheResult; +import io.smallrye.common.constraint.NotNull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; -import jakarta.validation.constraints.NotNull; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.exception.ConstraintViolationException; - @ApplicationScoped @RequiredArgsConstructor @Slf4j @@ -35,7 +34,7 @@ public class MessageLogRegistrationService { log.debug("{}Saved message log guild with ID={}{}", AnsiColor.GREEN, dto.getGuildId(), AnsiColor.RESET); return dto; } catch (ConstraintViolationException e) { // from hibernate - throw new GuildRegistrationException(e); + throw new GuildRegistrationFailureException(e); } } @@ -69,12 +68,9 @@ public class MessageLogRegistrationService { @CacheInvalidate(cacheName = "messageLog") public void deleteRegisteredGuild(@NonNull @CacheKey Long guildId) { - repository.findByIdOptional(guildId) - .orElseThrow(() -> new GuildNotFoundException("Guild is not registered for message logging")); - if (repository.deleteById(guildId)) log.debug("{} Deleted message log guild with ID={}{}", AnsiColor.GREEN, guildId, AnsiColor.RESET); else - log.warn("{}Failed to delete message log guild with ID={}{}", AnsiColor.YELLOW, guildId, AnsiColor.RESET); + throw new GuildNotFoundException("Guild is not registered for message logging"); } } diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/locks/LockDisabledMessageContentOperationImpl.java b/src/main/java/io/github/eggy03/papertrail/api/service/locks/LockDisabledMessageContentOperationImpl.java new file mode 100644 index 0000000..1e5d597 --- /dev/null +++ b/src/main/java/io/github/eggy03/papertrail/api/service/locks/LockDisabledMessageContentOperationImpl.java @@ -0,0 +1,40 @@ +package io.github.eggy03.papertrail.api.service.locks; + +import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; +import io.github.eggy03.papertrail.api.service.MessageLogContentService; +import io.quarkus.arc.properties.IfBuildProperty; +import io.smallrye.common.constraint.NotNull; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@ApplicationScoped +@IfBuildProperty(name = "message.locks.enabled", stringValue = "false", enableIfMissing = true) +@RequiredArgsConstructor +public class LockDisabledMessageContentOperationImpl implements MessageLogContentOperation { + + private final MessageLogContentService delegate; + + @Override + @NotNull + public MessageLogContentDTO saveMessage(@NonNull MessageLogContentDTO dto) { + return delegate.saveMessage(dto); + } + + @Override + @NotNull + public MessageLogContentDTO getMessage(@NonNull Long messageId) { + return delegate.getMessage(messageId); + } + + @Override + @NotNull + public MessageLogContentDTO updateMessage(@NonNull Long messageId, @NonNull MessageLogContentDTO dto) { + return delegate.updateMessage(messageId, dto); + } + + @Override + public void deleteMessage(@NonNull Long messageId) { + delegate.deleteMessage(messageId); + } +} diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentLockingService.java b/src/main/java/io/github/eggy03/papertrail/api/service/locks/LockEnabledMessageContentOperationImpl.java similarity index 87% rename from src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentLockingService.java rename to src/main/java/io/github/eggy03/papertrail/api/service/locks/LockEnabledMessageContentOperationImpl.java index d8ebaae..4b26276 100644 --- a/src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentLockingService.java +++ b/src/main/java/io/github/eggy03/papertrail/api/service/locks/LockEnabledMessageContentOperationImpl.java @@ -2,8 +2,9 @@ import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; import io.github.eggy03.papertrail.api.service.MessageLogContentService; +import io.quarkus.arc.properties.IfBuildProperty; +import io.smallrye.common.constraint.NotNull; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.validation.constraints.NotNull; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,12 +27,13 @@ maybe vertical or horizontal scaling would help more to keep up with the resource pressure */ @ApplicationScoped +@IfBuildProperty(name = "message.locks.enabled", stringValue = "true") @RequiredArgsConstructor @Slf4j -public class MessageLogContentLockingService { +public class LockEnabledMessageContentOperationImpl implements MessageLogContentOperation { private final RedissonClient redissonClient; - private final MessageLogContentService service; + private final MessageLogContentService delegate; @NotNull public MessageLogContentDTO saveMessage(@NonNull MessageLogContentDTO dto) { @@ -41,7 +43,7 @@ public MessageLogContentDTO saveMessage(@NonNull MessageLogContentDTO dto) { log.debug("Acquired SAVE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); try { - return service.saveMessage(dto); + return delegate.saveMessage(dto); } finally { rlock.unlock(); log.debug("Released SAVE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); @@ -79,7 +81,7 @@ public MessageLogContentDTO getMessage(@NonNull Long messageId) { log.debug("Acquired VIEW lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); try { - return service.getMessage(messageId); + return delegate.getMessage(messageId); } finally { rlock.unlock(); log.debug("Released VIEW lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); @@ -87,14 +89,14 @@ public MessageLogContentDTO getMessage(@NonNull Long messageId) { } @NotNull - public MessageLogContentDTO updateMessage(@NonNull MessageLogContentDTO dto) { + public MessageLogContentDTO updateMessage(@NonNull Long messageId, @NonNull MessageLogContentDTO dto) { RLock rlock = redissonClient.getFairLock(dto.getMessageId().toString()); rlock.lock(); log.debug("Acquired UPDATE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); try { - return service.updateMessage(dto.getMessageId(), dto); + return delegate.updateMessage(messageId, dto); } finally { rlock.unlock(); log.debug("Released UPDATE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); @@ -108,7 +110,7 @@ public void deleteMessage(@NonNull Long messageId) { log.debug("Acquired DELETE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); try { - service.deleteMessage(messageId); + delegate.deleteMessage(messageId); } finally { rlock.unlock(); log.debug("Released DELETE lock for messageID {} with active lock count {}", rlock.getName(), rlock.getHoldCount()); diff --git a/src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentOperation.java b/src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentOperation.java new file mode 100644 index 0000000..85c97d6 --- /dev/null +++ b/src/main/java/io/github/eggy03/papertrail/api/service/locks/MessageLogContentOperation.java @@ -0,0 +1,22 @@ +package io.github.eggy03.papertrail.api.service.locks; + +import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; +import lombok.NonNull; + +/** + * This interface has a signature matching {@link io.github.eggy03.papertrail.api.service.MessageLogContentService} + * Implementations of this interface will usually wrap the above described service methods in redisson locks + * or without it. + *

+ * Quarkus CDI will choose the implementation based on profiles + */ +public interface MessageLogContentOperation { + + MessageLogContentDTO saveMessage(@NonNull MessageLogContentDTO dto); + + MessageLogContentDTO getMessage(@NonNull Long messageId); + + MessageLogContentDTO updateMessage(@NonNull Long messageId, @NonNull MessageLogContentDTO dto); + + void deleteMessage(@NonNull Long messageId); +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index d3abc27..fd9bcf6 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,24 +1,18 @@ # Database -quarkus.datasource.db-kind=h2 -quarkus.datasource.username=sa -quarkus.datasource.password= +quarkus.datasource.db-kind=postgresql +quarkus.datasource.devservices.image-name=postgres:18.2-alpine -quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 -quarkus.datasource.jdbc.max-size=16 quarkus.hibernate-orm.database.default-schema=papertrailbot -quarkus.hibernate-orm.schema-management.strategy=drop-and-create +quarkus.hibernate-orm.schema-management.strategy=validate + # Flyway quarkus.flyway.migrate-at-start=true quarkus.flyway.create-schemas=true quarkus.flyway.schemas=papertrailbot quarkus.flyway.default-schema=papertrailbot - -# Global Redis Settings -# (don't configure in dev mode so that dev tools can automatically spin up a container having redis if ur docker is running) -# quarkus.redis.hosts=redis://localhost:6379 -# quarkus.redis.password= - +# Redis quarkus.cache.type=redis +quarkus.redis.devservices.image-name=redis:8.6.1-alpine # Redis Cache Settings quarkus.cache.redis."auditLog".ttl=30D @@ -31,6 +25,9 @@ quarkus.redisson.threads=16 quarkus.redisson.netty-threads=32 # Logging Level -quarkus.log.level=DEBUG +quarkus.log.level=INFO + # Analytics quarkus.analytics.disabled=true +# Custom +message.locks.enabled=true diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 0d64d3b..cff424c 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -33,5 +33,8 @@ quarkus.http.port=${PORT:8080} # Logging Level quarkus.log.level=INFO + # Analytics -quarkus.analytics.disabled=true \ No newline at end of file +quarkus.analytics.disabled=true +# Custom +message.locks.enabled=true \ No newline at end of file diff --git a/src/test/java/integration/AuditLogRegistrationServiceIntegrationTest.java b/src/test/java/integration/AuditLogRegistrationServiceIntegrationTest.java new file mode 100644 index 0000000..f2cd158 --- /dev/null +++ b/src/test/java/integration/AuditLogRegistrationServiceIntegrationTest.java @@ -0,0 +1,289 @@ +package integration; + +import io.github.eggy03.papertrail.api.dto.AuditLogRegistrationDTO; +import io.github.eggy03.papertrail.api.entity.AuditLogRegistration; +import io.github.eggy03.papertrail.api.repository.AuditLogRegistrationRepository; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Optional; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +@QuarkusTest +class AuditLogRegistrationServiceIntegrationTest { + + private static final String BASE_PATH = "/api/v1/log/audit"; + + @Inject + AuditLogRegistrationRepository repository; + + // RedisDataSource while not annotated for CDI, does get injected because Quarkus handles this synthetic bean + // Or IntelliJ does not see the dependencies + // see https://github.com/quarkiverse/quarkus-minio/issues/413 and https://github.com/quarkusio/quarkus/discussions/25120 + @Inject + RedisDataSource redisDataSource; + + static final Long TEST_GUILD_ID = 1302148573926148096L; + static final Long TEST_CHANNEL_ID = 1302148573926148097L; + + static final Long NEGATIVE_TEST_GUILD_ID = -1302148573926148096L; + static final Long NEGATIVE_TEST_CHANNEL_ID = -1302148573926148097L; + + // prep a valid Entity + final AuditLogRegistration validEntity = new AuditLogRegistration(TEST_GUILD_ID, TEST_CHANNEL_ID); + // prep a valid DTO + final AuditLogRegistrationDTO validDTO = new AuditLogRegistrationDTO(TEST_GUILD_ID, TEST_CHANNEL_ID); + + // prep a stream of invalid DTOs + public static Stream invalidDTOs() { + + AuditLogRegistrationDTO nullBodyDTO = new AuditLogRegistrationDTO(null, null); + AuditLogRegistrationDTO nullGuildIdDTO = new AuditLogRegistrationDTO(null, TEST_CHANNEL_ID); + AuditLogRegistrationDTO nullChannelIdDTO = new AuditLogRegistrationDTO(TEST_GUILD_ID, null); + + AuditLogRegistrationDTO negativeGuildIdDTO = new AuditLogRegistrationDTO(NEGATIVE_TEST_GUILD_ID, TEST_CHANNEL_ID); + AuditLogRegistrationDTO negativeChannelIdDTO = new AuditLogRegistrationDTO(TEST_GUILD_ID, NEGATIVE_TEST_CHANNEL_ID); + + return Stream.of(nullBodyDTO, nullGuildIdDTO, nullChannelIdDTO, negativeGuildIdDTO, negativeChannelIdDTO); + } + + @BeforeEach + void cleanState() { + QuarkusTransaction.requiringNew().run(repository::deleteAll); + redisDataSource.flushall(); + } + + @Test + void registerGuild_success() { + + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + // assert that save was a success + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(AuditLogRegistration::getGuildId, AuditLogRegistration::getChannelId) + .containsExactly(TEST_GUILD_ID, TEST_CHANNEL_ID); + } + + @Test + void registerGuild_alreadyExists_conflicts() { + + // register once, expect success + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + // register again, expect 409 conflict + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(409); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void registerGuild_validationFails_badRequest(AuditLogRegistrationDTO dto) { + + given().contentType("application/json").body(dto) + .when().post(BASE_PATH) + .then().statusCode(400); + + // assert that nothing was saved + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void registerGuild_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().post(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void getGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // view registered guild - expect success + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(200) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + } + + @Test + void getGuild_notRegistered_notFound() { + + // view un-registered guild - expect not found + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(404); + + } + + @Test + void getGuild_invalidParameters() { + + given().contentType("application/json") + .when().get(BASE_PATH + "/" + NEGATIVE_TEST_GUILD_ID) + .then().statusCode(400); + + } + + @Test + void updateGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // update + AuditLogRegistrationDTO dto = new AuditLogRegistrationDTO(TEST_GUILD_ID, 1302148579426154496L); + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(200) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(1302148579426154496L)); + + // verify update + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(AuditLogRegistration::getGuildId, AuditLogRegistration::getChannelId) + .containsExactly(TEST_GUILD_ID, 1302148579426154496L); + + } + + @Test + void updateGuild_doesNotExist() { + + // update without registering + given().contentType("application/json").body(validDTO) + .when().put(BASE_PATH) + .then().statusCode(404); + + // verify update didn't register a new guild + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void updateGuild_validationFails_badRequest(AuditLogRegistrationDTO dto) { + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(400); + + // assert that nothing was updated + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void updateGuild_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().put(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void deleteGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(204); + + // verify deletion + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteGuild_doesNotExist_notFound() { + + // attempt delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(404); + + // verify guild actually does not exist + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteGuild_invalidParameters() { + + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + NEGATIVE_TEST_GUILD_ID) + .then().statusCode(400); + + } + +} diff --git a/src/test/java/integration/MessageLogContentServiceIntegrationTest.java b/src/test/java/integration/MessageLogContentServiceIntegrationTest.java new file mode 100644 index 0000000..69f94ab --- /dev/null +++ b/src/test/java/integration/MessageLogContentServiceIntegrationTest.java @@ -0,0 +1,294 @@ +package integration; + +import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; +import io.github.eggy03.papertrail.api.entity.MessageLogContent; +import io.github.eggy03.papertrail.api.repository.MessageLogContentRepository; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Optional; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +@QuarkusTest +class MessageLogContentServiceIntegrationTest { + + private static final String BASE_PATH = "/api/v1/content/message"; + + @Inject + MessageLogContentRepository repository; + + // RedisDataSource while not annotated for CDI, does get injected because Quarkus handles this synthetic bean + // Or IntelliJ does not see the dependencies + // see https://github.com/quarkiverse/quarkus-minio/issues/413 and https://github.com/quarkusio/quarkus/discussions/25120 + @Inject + RedisDataSource redisDataSource; + + static final Long TEST_MESSAGE_ID = 1302148573926148096L; + static final String TEST_MESSAGE_CONTENT = "message"; + static final Long TEST_AUTHOR_ID = 1302148573926148097L; + + static final Long NEGATIVE_TEST_MESSAGE_ID = -1302148573926148096L; + static final Long NEGATIVE_TEST_AUTHOR_ID = -1302148573926148097L; + + // prep a valid Entity + final MessageLogContent validEntity = new MessageLogContent(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID, null); + // prep a valid DTO + final MessageLogContentDTO validDTO = new MessageLogContentDTO(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID); + + // prep a stream of invalid DTOs + public static Stream invalidDTOs() { + + MessageLogContentDTO nullBodyDTO = new MessageLogContentDTO(null, null, null); + MessageLogContentDTO nullMessageIdDTO = new MessageLogContentDTO(null, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID); + MessageLogContentDTO nullMessageContentDTO = new MessageLogContentDTO(TEST_MESSAGE_ID, null, TEST_AUTHOR_ID); + MessageLogContentDTO nullAuthorIdDTO = new MessageLogContentDTO(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, null); + + MessageLogContentDTO negativeMessageIdDTO = new MessageLogContentDTO(NEGATIVE_TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID); + MessageLogContentDTO negativeAuthorIdDTO = new MessageLogContentDTO(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, NEGATIVE_TEST_AUTHOR_ID); + + return Stream.of(nullBodyDTO, nullMessageIdDTO, nullMessageContentDTO, nullAuthorIdDTO, negativeMessageIdDTO, negativeAuthorIdDTO); + } + + @BeforeEach + void cleanState() { + QuarkusTransaction.requiringNew().run(repository::deleteAll); + redisDataSource.flushall(); + } + + @Test + void saveMessage_success() { + + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("messageId", is(TEST_MESSAGE_ID)) + .body("messageContent", is(TEST_MESSAGE_CONTENT)) + .body("authorId", is(TEST_AUTHOR_ID)); + + // assert that save was a success + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(MessageLogContent::getMessageId, MessageLogContent::getMessageContent, MessageLogContent::getAuthorId) + .containsExactly(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID); + } + + @Test + void saveMessage_alreadyExists_conflicts() { + + // save once, expect success + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("messageId", is(TEST_MESSAGE_ID)) + .body("messageContent", is(TEST_MESSAGE_CONTENT)) + .body("authorId", is(TEST_AUTHOR_ID)); + + // save again, expect 409 conflict + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(409); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void saveMessage_validationFails_badRequest(MessageLogContentDTO dto) { + + given().contentType("application/json").body(dto) + .when().post(BASE_PATH) + .then().statusCode(400); + + // assert that nothing was saved + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_MESSAGE_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void saveMessage_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().post(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void getMessage_success() { + + // save to db + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // view message - expect success + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_MESSAGE_ID) + .then().statusCode(200) + .body("messageId", is(TEST_MESSAGE_ID)) + .body("messageContent", is(TEST_MESSAGE_CONTENT)) + .body("authorId", is(TEST_AUTHOR_ID)); + + } + + @Test + void getMessage_notSaved_notFound() { + + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_MESSAGE_ID) + .then().statusCode(404); + + } + + @Test + void getMessage_invalidParameters() { + + given().contentType("application/json") + .when().get(BASE_PATH + "/" + NEGATIVE_TEST_MESSAGE_ID) + .then().statusCode(400); + + } + + @Test + void updateMessage_success() { + + // save message + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // create an updated DTO + MessageLogContentDTO dto = new MessageLogContentDTO(TEST_MESSAGE_ID, "updatedMessage", TEST_AUTHOR_ID); + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(200) + .body("messageId", is(TEST_MESSAGE_ID)) + .body("messageContent", is("updatedMessage")) + .body("authorId", is(TEST_AUTHOR_ID)); + + // verify update + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(MessageLogContent::getMessageId, MessageLogContent::getMessageContent, MessageLogContent::getAuthorId) + .containsExactly(TEST_MESSAGE_ID, "updatedMessage", TEST_AUTHOR_ID); + + } + + @Test + void updateMessage_doesNotExist_notFound() { + + // update without saving first + given().contentType("application/json").body(validDTO) + .when().put(BASE_PATH) + .then().statusCode(404); + + // verify update didn't register a new guild + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void updateGuild_validationFails_badRequest(MessageLogContentDTO dto) { + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(400); + + // verify that updates were not applied + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_MESSAGE_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void updateGuild_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().put(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void deleteMessage_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_MESSAGE_ID) + .then().statusCode(204); + + // verify deletion + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteMessage_doesNotExist_notFound() { + + // attempt delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_MESSAGE_ID) + .then().statusCode(404); + + // verify guild actually does not exist + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_MESSAGE_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteMessage_invalidParameters() { + + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + NEGATIVE_TEST_MESSAGE_ID) + .then().statusCode(400); + + } + +} diff --git a/src/test/java/integration/MessageLogRegistrationServiceIntegrationTest.java b/src/test/java/integration/MessageLogRegistrationServiceIntegrationTest.java new file mode 100644 index 0000000..73aa315 --- /dev/null +++ b/src/test/java/integration/MessageLogRegistrationServiceIntegrationTest.java @@ -0,0 +1,289 @@ +package integration; + +import io.github.eggy03.papertrail.api.dto.MessageLogRegistrationDTO; +import io.github.eggy03.papertrail.api.entity.MessageLogRegistration; +import io.github.eggy03.papertrail.api.repository.MessageLogRegistrationRepository; +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Optional; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; + +@QuarkusTest +class MessageLogRegistrationServiceIntegrationTest { + + private static final String BASE_PATH = "/api/v1/log/message"; + + @Inject + MessageLogRegistrationRepository repository; + + // RedisDataSource while not annotated for CDI, does get injected because Quarkus handles this synthetic bean + // Or IntelliJ does not see the dependencies + // see https://github.com/quarkiverse/quarkus-minio/issues/413 and https://github.com/quarkusio/quarkus/discussions/25120 + @Inject + RedisDataSource redisDataSource; + + static final Long TEST_GUILD_ID = 1302148573926148096L; + static final Long TEST_CHANNEL_ID = 1302148573926148097L; + + static final Long NEGATIVE_TEST_GUILD_ID = -1302148573926148096L; + static final Long NEGATIVE_TEST_CHANNEL_ID = -1302148573926148097L; + + // prep a valid Entity + final MessageLogRegistration validEntity = new MessageLogRegistration(TEST_GUILD_ID, TEST_CHANNEL_ID); + // prep a valid DTO + final MessageLogRegistrationDTO validDTO = new MessageLogRegistrationDTO(TEST_GUILD_ID, TEST_CHANNEL_ID); + + // prep a stream of invalid DTOs + public static Stream invalidDTOs() { + + MessageLogRegistrationDTO nullBodyDTO = new MessageLogRegistrationDTO(null, null); + MessageLogRegistrationDTO nullGuildIdDTO = new MessageLogRegistrationDTO(null, TEST_CHANNEL_ID); + MessageLogRegistrationDTO nullChannelIdDTO = new MessageLogRegistrationDTO(TEST_GUILD_ID, null); + + MessageLogRegistrationDTO negativeGuildIdDTO = new MessageLogRegistrationDTO(NEGATIVE_TEST_GUILD_ID, TEST_CHANNEL_ID); + MessageLogRegistrationDTO negativeChannelIdDTO = new MessageLogRegistrationDTO(TEST_GUILD_ID, NEGATIVE_TEST_CHANNEL_ID); + + return Stream.of(nullBodyDTO, nullGuildIdDTO, nullChannelIdDTO, negativeGuildIdDTO, negativeChannelIdDTO); + } + + @BeforeEach + void cleanState() { + QuarkusTransaction.requiringNew().run(repository::deleteAll); + redisDataSource.flushall(); + } + + @Test + void registerGuild_success() { + + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + // assert that save was a success + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(MessageLogRegistration::getGuildId, MessageLogRegistration::getChannelId) + .containsExactly(TEST_GUILD_ID, TEST_CHANNEL_ID); + } + + @Test + void registerGuild_alreadyExists_conflicts() { + + // register once, expect success + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(201) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + // register again, expect 409 conflict + given().contentType("application/json").body(validDTO) + .when().post(BASE_PATH) + .then().statusCode(409); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void registerGuild_validationFails_badRequest(MessageLogRegistrationDTO dto) { + + given().contentType("application/json").body(dto) + .when().post(BASE_PATH) + .then().statusCode(400); + + // assert that nothing was saved + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void registerGuild_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().post(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void getGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // view registered guild - expect success + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(200) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(TEST_CHANNEL_ID)); + + } + + @Test + void getGuild_notRegistered_notFound() { + + // view un-registered guild - expect not found + given().contentType("application/json") + .when().get(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(404); + + } + + @Test + void getGuild_invalidParameters() { + + given().contentType("application/json") + .when().get(BASE_PATH + "/" + NEGATIVE_TEST_GUILD_ID) + .then().statusCode(400); + + } + + @Test + void updateGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // update + MessageLogRegistrationDTO dto = new MessageLogRegistrationDTO(TEST_GUILD_ID, 1302148579426154496L); + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(200) + .body("guildId", is(TEST_GUILD_ID)) + .body("channelId", is(1302148579426154496L)); + + // verify update + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional) + .isPresent() + .get() + .extracting(MessageLogRegistration::getGuildId, MessageLogRegistration::getChannelId) + .containsExactly(TEST_GUILD_ID, 1302148579426154496L); + + } + + @Test + void updateGuild_doesNotExist() { + + // update without registering + given().contentType("application/json").body(validDTO) + .when().put(BASE_PATH) + .then().statusCode(404); + + // verify update didn't register a new guild + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @ParameterizedTest + @MethodSource("invalidDTOs") + void updateGuild_validationFails_badRequest(MessageLogRegistrationDTO dto) { + + given().contentType("application/json").body(dto) + .when().put(BASE_PATH) + .then().statusCode(400); + + // assert that nothing was updated + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + Optional entityOptionalTwo = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(NEGATIVE_TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + assertThat(entityOptionalTwo).isEmpty(); + } + + @Test + void updateGuild_deserializationFails_badRequest() { + + given().contentType("application/json").body("\"text\"") + .when().put(BASE_PATH) + .then().statusCode(400); + + } + + @Test + void deleteGuild_success() { + + // register + QuarkusTransaction.requiringNew().run(() -> repository.persistAndFlush(validEntity)); + + // delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(204); + + // verify deletion + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteGuild_doesNotExist_notFound() { + + // attempt delete + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + TEST_GUILD_ID) + .then().statusCode(404); + + // verify guild actually does not exist + Optional entityOptional = QuarkusTransaction + .requiringNew() + .call(() -> repository.findByIdOptional(TEST_GUILD_ID)); + + assertThat(entityOptional).isEmpty(); + + } + + @Test + void deleteGuild_invalidParameters() { + + given().contentType("application/json") + .when().delete(BASE_PATH + "/" + NEGATIVE_TEST_GUILD_ID) + .then().statusCode(400); + + } + +} diff --git a/src/test/java/unit/AuditLogRegistrationServiceUnitTest.java b/src/test/java/unit/AuditLogRegistrationServiceUnitTest.java new file mode 100644 index 0000000..b62d666 --- /dev/null +++ b/src/test/java/unit/AuditLogRegistrationServiceUnitTest.java @@ -0,0 +1,142 @@ +package unit; + +import io.github.eggy03.papertrail.api.dto.AuditLogRegistrationDTO; +import io.github.eggy03.papertrail.api.entity.AuditLogRegistration; +import io.github.eggy03.papertrail.api.exceptions.GuildNotFoundException; +import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationFailureException; +import io.github.eggy03.papertrail.api.mapper.AuditLogRegistrationMapper; +import io.github.eggy03.papertrail.api.repository.AuditLogRegistrationRepository; +import io.github.eggy03.papertrail.api.service.AuditLogRegistrationService; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogRegistrationServiceUnitTest { + + static final Long TEST_GUILD_ID = 1302148573926148096L; + static final Long TEST_CHANNEL_ID = 1302148573926148097L; + // prep a valid Entity + final AuditLogRegistration validEntity = new AuditLogRegistration(TEST_GUILD_ID, TEST_CHANNEL_ID); + // prep a valid DTO + final AuditLogRegistrationDTO validDTO = new AuditLogRegistrationDTO(TEST_GUILD_ID, TEST_CHANNEL_ID); + @Mock + AuditLogRegistrationRepository repository; + @Mock + AuditLogRegistrationMapper mapper; + @InjectMocks + AuditLogRegistrationService service; + + @Test + void registerGuild_success() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + + service.registerGuild(validDTO); + + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(repository, mapper); + } + + @Test + void registerGuild_alreadyExists_conflicts() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + doThrow(ConstraintViolationException.class).when(repository).persistAndFlush(validEntity); + + assertThrows(GuildRegistrationFailureException.class, () -> service.registerGuild(validDTO)); + + verify(mapper).toEntity(validDTO); + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getGuild_success() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.of(validEntity)); + when(mapper.toDTO(validEntity)).thenReturn(validDTO); + + AuditLogRegistrationDTO result = service.viewRegisteredGuild(TEST_GUILD_ID); + assertThat(result).isEqualTo(validDTO); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verify(mapper).toDTO(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getGuild_notRegistered_notFound() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.empty()); + + assertThrows(GuildNotFoundException.class, () -> service.viewRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verify(mapper, never()).toDTO(any()); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void updateGuild_success() { + + AuditLogRegistration oldEntity = new AuditLogRegistration(TEST_GUILD_ID, 123L); + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.of(oldEntity)); + + service.updateRegisteredGuild(TEST_GUILD_ID, validDTO); + assertThat(oldEntity.getChannelId()).isEqualTo(validDTO.getChannelId()); // confirm that old entity was mutated with new dto data + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + + } + + @Test + void updateGuild_doesNotExist() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.empty()); + + assertThrows(GuildNotFoundException.class, () -> service.updateRegisteredGuild(TEST_GUILD_ID, validDTO)); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } + + + @Test + void deleteGuild_success() { + + when(repository.deleteById(TEST_GUILD_ID)).thenReturn(true); + + assertDoesNotThrow(() -> service.deleteRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).deleteById(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void deleteGuild_doesNotExist_notFound() { + + when(repository.deleteById(TEST_GUILD_ID)).thenReturn(false); + + assertThrows(GuildNotFoundException.class, () -> service.deleteRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).deleteById(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } +} diff --git a/src/test/java/unit/MessageLogContentServiceUnitTest.java b/src/test/java/unit/MessageLogContentServiceUnitTest.java new file mode 100644 index 0000000..f8983b4 --- /dev/null +++ b/src/test/java/unit/MessageLogContentServiceUnitTest.java @@ -0,0 +1,144 @@ +package unit; + +import io.github.eggy03.papertrail.api.dto.MessageLogContentDTO; +import io.github.eggy03.papertrail.api.entity.MessageLogContent; +import io.github.eggy03.papertrail.api.exceptions.MessageNotFoundException; +import io.github.eggy03.papertrail.api.exceptions.MessageSaveFailureException; +import io.github.eggy03.papertrail.api.mapper.MessageLogContentMapper; +import io.github.eggy03.papertrail.api.repository.MessageLogContentRepository; +import io.github.eggy03.papertrail.api.service.MessageLogContentService; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MessageLogContentServiceUnitTest { + + static final Long TEST_MESSAGE_ID = 1302148573926148096L; + static final String TEST_MESSAGE_CONTENT = "message"; + static final Long TEST_AUTHOR_ID = 1302148573926148097L; + // prep a valid Entity + final MessageLogContent validEntity = new MessageLogContent(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID, null); + // prep a valid DTO + final MessageLogContentDTO validDTO = new MessageLogContentDTO(TEST_MESSAGE_ID, TEST_MESSAGE_CONTENT, TEST_AUTHOR_ID); + @Mock + MessageLogContentRepository repository; + @Mock + MessageLogContentMapper mapper; + @InjectMocks + MessageLogContentService service; + + @Test + void saveMessage_success() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + + service.saveMessage(validDTO); + + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(repository, mapper); + } + + @Test + void saveMessage_alreadyExists_conflicts() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + doThrow(ConstraintViolationException.class).when(repository).persistAndFlush(validEntity); + + assertThrows(MessageSaveFailureException.class, () -> service.saveMessage(validDTO)); + + verify(mapper).toEntity(validDTO); + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getMessage_success() { + + when(repository.findByIdOptional(TEST_MESSAGE_ID)).thenReturn(Optional.of(validEntity)); + when(mapper.toDTO(validEntity)).thenReturn(validDTO); + + MessageLogContentDTO result = service.getMessage(TEST_MESSAGE_ID); + assertThat(result).isEqualTo(validDTO); + + verify(repository).findByIdOptional(TEST_MESSAGE_ID); + verify(mapper).toDTO(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getMessage_notSaved_notFound() { + + when(repository.findByIdOptional(TEST_MESSAGE_ID)).thenReturn(Optional.empty()); + + assertThrows(MessageNotFoundException.class, () -> service.getMessage(TEST_MESSAGE_ID)); + + verify(repository).findByIdOptional(TEST_MESSAGE_ID); + verify(mapper, never()).toDTO(any()); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void updateMessage_success() { + + MessageLogContent oldEntity = new MessageLogContent(TEST_MESSAGE_ID, "oldMessage", 123L, null); + when(repository.findByIdOptional(TEST_MESSAGE_ID)).thenReturn(Optional.of(oldEntity)); + + service.updateMessage(TEST_MESSAGE_ID, validDTO); + assertThat(oldEntity.getMessageContent()).isEqualTo(validDTO.getMessageContent()); + assertThat(oldEntity.getAuthorId()).isEqualTo(validDTO.getAuthorId());// confirm that old entity was mutated with new dto data + + verify(repository).findByIdOptional(TEST_MESSAGE_ID); + verifyNoMoreInteractions(repository); + + } + + @Test + void updateMessage_doesNotExist() { + + when(repository.findByIdOptional(TEST_MESSAGE_ID)).thenReturn(Optional.empty()); + + assertThrows(MessageNotFoundException.class, () -> service.updateMessage(TEST_MESSAGE_ID, validDTO)); + + verify(repository).findByIdOptional(TEST_MESSAGE_ID); + verifyNoMoreInteractions(repository); + } + + + @Test + void deleteMessage_success() { + + when(repository.deleteById(TEST_MESSAGE_ID)).thenReturn(true); + + assertDoesNotThrow(() -> service.deleteMessage(TEST_MESSAGE_ID)); + + verify(repository).deleteById(TEST_MESSAGE_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void deleteMessage_doesNotExist_notFound() { + + when(repository.deleteById(TEST_MESSAGE_ID)).thenReturn(false); + + assertThrows(MessageNotFoundException.class, () -> service.deleteMessage(TEST_MESSAGE_ID)); + + verify(repository).deleteById(TEST_MESSAGE_ID); + verifyNoMoreInteractions(repository); + } +} diff --git a/src/test/java/unit/MessageLogRegistrationServiceUnitTest.java b/src/test/java/unit/MessageLogRegistrationServiceUnitTest.java new file mode 100644 index 0000000..fde0968 --- /dev/null +++ b/src/test/java/unit/MessageLogRegistrationServiceUnitTest.java @@ -0,0 +1,142 @@ +package unit; + +import io.github.eggy03.papertrail.api.dto.MessageLogRegistrationDTO; +import io.github.eggy03.papertrail.api.entity.MessageLogRegistration; +import io.github.eggy03.papertrail.api.exceptions.GuildNotFoundException; +import io.github.eggy03.papertrail.api.exceptions.GuildRegistrationFailureException; +import io.github.eggy03.papertrail.api.mapper.MessageLogRegistrationMapper; +import io.github.eggy03.papertrail.api.repository.MessageLogRegistrationRepository; +import io.github.eggy03.papertrail.api.service.MessageLogRegistrationService; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MessageLogRegistrationServiceUnitTest { + + static final Long TEST_GUILD_ID = 1302148573926148096L; + static final Long TEST_CHANNEL_ID = 1302148573926148097L; + // prep a valid Entity + final MessageLogRegistration validEntity = new MessageLogRegistration(TEST_GUILD_ID, TEST_CHANNEL_ID); + // prep a valid DTO + final MessageLogRegistrationDTO validDTO = new MessageLogRegistrationDTO(TEST_GUILD_ID, TEST_CHANNEL_ID); + @Mock + MessageLogRegistrationRepository repository; + @Mock + MessageLogRegistrationMapper mapper; + @InjectMocks + MessageLogRegistrationService service; + + @Test + void registerGuild_success() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + + service.registerGuild(validDTO); + + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(repository, mapper); + } + + @Test + void registerGuild_alreadyExists_conflicts() { + + when(mapper.toEntity(validDTO)).thenReturn(validEntity); + doThrow(ConstraintViolationException.class).when(repository).persistAndFlush(validEntity); + + assertThrows(GuildRegistrationFailureException.class, () -> service.registerGuild(validDTO)); + + verify(mapper).toEntity(validDTO); + verify(repository).persistAndFlush(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getGuild_success() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.of(validEntity)); + when(mapper.toDTO(validEntity)).thenReturn(validDTO); + + MessageLogRegistrationDTO result = service.viewRegisteredGuild(TEST_GUILD_ID); + assertThat(result).isEqualTo(validDTO); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verify(mapper).toDTO(validEntity); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void getGuild_notRegistered_notFound() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.empty()); + + assertThrows(GuildNotFoundException.class, () -> service.viewRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verify(mapper, never()).toDTO(any()); + verifyNoMoreInteractions(mapper, repository); + } + + @Test + void updateGuild_success() { + + MessageLogRegistration oldEntity = new MessageLogRegistration(TEST_GUILD_ID, 123L); + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.of(oldEntity)); + + service.updateRegisteredGuild(TEST_GUILD_ID, validDTO); + assertThat(oldEntity.getChannelId()).isEqualTo(validDTO.getChannelId()); // confirm that old entity was mutated with new dto data + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + + } + + @Test + void updateGuild_doesNotExist() { + + when(repository.findByIdOptional(TEST_GUILD_ID)).thenReturn(Optional.empty()); + + assertThrows(GuildNotFoundException.class, () -> service.updateRegisteredGuild(TEST_GUILD_ID, validDTO)); + + verify(repository).findByIdOptional(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } + + + @Test + void deleteGuild_success() { + + when(repository.deleteById(TEST_GUILD_ID)).thenReturn(true); + + assertDoesNotThrow(() -> service.deleteRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).deleteById(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } + + @Test + void deleteGuild_doesNotExist_notFound() { + + when(repository.deleteById(TEST_GUILD_ID)).thenReturn(false); + + assertThrows(GuildNotFoundException.class, () -> service.deleteRegisteredGuild(TEST_GUILD_ID)); + + verify(repository).deleteById(TEST_GUILD_ID); + verifyNoMoreInteractions(repository); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..2c76e23 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,21 @@ +quarkus.http.test-port=0 +# DB +quarkus.datasource.db-kind=postgresql +quarkus.datasource.devservices.enabled=true +quarkus.datasource.devservices.image-name=postgres:18.2-alpine +quarkus.hibernate-orm.database.default-schema=papertrailbot +quarkus.hibernate-orm.schema-management.strategy=validate + +# Flyway +quarkus.flyway.migrate-at-start=true +quarkus.flyway.create-schemas=true +quarkus.flyway.schemas=papertrailbot +quarkus.flyway.default-schema=papertrailbot +# Redis +quarkus.redis.devservices.enabled=true +quarkus.redis.devservices.image-name=redis:8.6.1-alpine +# Logging Level +quarkus.log.level=INFO +# Custom +message.locks.enabled=false +