diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c3ffdc5..a1488af 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,6 +17,8 @@ jobs: - name: Run chmod to make gradlew executable run: chmod +x gradlew - name: Execute tests + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} uses: gradle/gradle-build-action@v3 with: - arguments: cleanTest test + arguments: cleanTest test jacocoTestReport sonar diff --git a/build.gradle b/build.gradle index c10a0d6..ada93d5 100644 --- a/build.gradle +++ b/build.gradle @@ -4,12 +4,31 @@ plugins { id 'org.springframework.boot' version '3.+' id 'io.spring.dependency-management' version '1.+' id "io.github.kobylynskyi.graphql.codegen" version "5.+" + id "org.sonarqube" version "4.+" + id "jacoco" } group = 'de.unistuttgart.iste.meitrex' version = '0.0.1-SNAPSHOT' sourceCompatibility = '21' +def jacocoEnabled = System.properties.getProperty("jacocoEnabled") ?: "true" + +// Apply JaCoCo settings only if jacaco is enable +if (jacocoEnabled.toBoolean()) { + project.logger.lifecycle('Applying jacoco settings from jacoco.gradle') + apply from: rootProject.file("jacoco.gradle") +} + +sonarqube { + properties { + property("sonar.projectKey", "MEITREX_common") + property("sonar.organization", "meitrex") + property("sonar.host.url", "https://sonarcloud.io") + } + +} + // Automatically generate DTOs from GraphQL schema: graphqlCodegen { // all config options: @@ -54,6 +73,7 @@ dependencies { implementation 'jakarta.validation:jakarta.validation-api' implementation 'io.dapr:dapr-sdk:1.+' // Dapr's core SDK with all features, except Actors. implementation 'io.dapr:dapr-sdk-springboot:1.+' // Dapr's SDK integration with SpringBoot + implementation 'org.modelmapper:modelmapper:3.+' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/jacoco.gradle b/jacoco.gradle new file mode 100644 index 0000000..79af5a1 --- /dev/null +++ b/jacoco.gradle @@ -0,0 +1,16 @@ +jacoco { + toolVersion = "0.8.11" +} + +test { + finalizedBy jacocoTestReport +} + + +jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(true) + html.outputLocation = layout.buildDirectory.dir('jacocoHtml') + } +} diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..21458e8 --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +lombok.addLombokGeneratedAnnotation = true +config.stopBubbling = true \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java b/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java new file mode 100644 index 0000000..bf5b008 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudService.java @@ -0,0 +1,196 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.exception.MeitrexNotFoundException; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.modelmapper.ModelMapper; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Abstract service for CRUD operations. + * Services extending this class can utilize the helper methods to perform CRUD operations on entities. + * + * @param the type of the entity id, e.g., UUID + * @param the type of the entity, e.g., UserEntity + * @param the type of the DTO, e.g., User + */ +public abstract class AbstractCrudService, D> { + + /** + * @return the class of the entity, e.g., UserEntity.class + */ + protected abstract Class getEntityClass(); + + /** + * @return the class of the DTO, e.g., User.class + */ + protected abstract Class getDtoClass(); + + /** + * Get the model mapper used to map between entities and DTOs. + * This method should return a pre-configured model mapper. + * The model mapper should be set up to map between the entity and DTO classes, + * and between input classes and the entity class. + * + * @return the model mapper used to map between entities and DTOs + */ + protected abstract ModelMapper getModelMapper(); + + /** + * @return the repository used to access the entities + */ + protected abstract MeitrexRepository getRepository(); + + /** + * Get all entities from the repository and convert them to DTOs. + * + * @return a list of all entities as DTOs + */ + protected List getAll() { + final List entities = getRepository().findAll(); + return convertToDtos(entities); + } + + /** + * Find an entity by its id and convert it to a DTO. + * + * @param id the id of the entity to find + * @return the entity as a DTO, or an empty optional if no entity with the given id exists + */ + protected Optional find(final I id) { + return getRepository() + .findById(id) + .map(this::convertToDto); + } + + /** + * Gets an entity by its id and convert it to a DTO. + * If no entity with the given id exists, throws an exception. + * + * @param id the id of the entity to get + * @return the entity as a DTO + * @throws MeitrexNotFoundException if no entity with the given id exists + */ + protected D getOrThrow(final I id) { + return convertToDto(getRepository().findByIdOrThrow(id)); + } + + /** + * Creates an entity using the given entity creator and saves it to the repository. + * + * @param entityCreator supplier that returns the entity to create + * @return the created entity as an entity + */ + protected E createEntity(final Supplier entityCreator) { + final E entity = entityCreator.get(); + return getRepository().save(entity); + } + + /** + * Creates an entity by mapping the given input to an entity and saves it to the repository. + * Note: This requires the model mapper to be set up correctly. + * + * @param createInput the input to map to an entity + * @return the created entity as an entity + */ + protected E createEntity(final Object createInput) { + return createEntity(() -> getModelMapper().map(createInput, getEntityClass())); + } + + /** + * Creates an entity using the given entity creator, saves it to the repository + * and converts it to a DTO. + * + * @param entityCreator supplier that returns the entity to create + * @return the created entity as a DTO + */ + protected D create(final Supplier entityCreator) { + final E entity = createEntity(entityCreator); + + return convertToDto(entity); + } + + /** + * Creates an entity by mapping the given input to an entity, saves it to the repository + * and converts it to a DTO. + * Note: This requires the model mapper to be set up correctly. + * + * @param createInput the input to map to an entity + * @return the created entity as a DTO + */ + protected D create(final Object createInput) { + return create(() -> getModelMapper().map(createInput, getEntityClass())); + } + + /** + * Updates an entity by its id using the given entity updater and saves it to the repository. + * + * @param id the id of the entity to update + * @param entityUpdater consumer that updates the entity + * @return the updated entity as a DTO + */ + protected D update(final I id, final Consumer entityUpdater) { + final E entity = getRepository().findByIdOrThrow(id); + entityUpdater.accept(entity); + + final E savedEntity = getRepository().save(entity); + + return convertToDto(savedEntity); + } + + /** + * Updates an entity by its id by mapping the given input + * to the existing entity and saves it to the repository. + * This will not overwrite the entity, but update only the fields + * that are defined in the input. + * + * @param id the id of the entity to update + * @param updateInput the input to map to an entity + * @return the updated entity as a DTO + */ + protected D update(final I id, final Object updateInput) { + return update(id, entity -> getModelMapper().map(updateInput, entity)); + } + + /** + * Deletes an entity by its id. + * + * @param id the id of the entity to delete + * @return true if the entity was deleted, false if no entity with the given id exists + * @apiNote subclasses will likely require a more sophisticated deletion logic + */ + protected boolean delete(final I id) { + if (!getRepository().existsById(id)) { + return false; + } + + getRepository().deleteById(id); + return true; + } + + /** + * Converts an entity to a DTO. + * + * @param entity the entity to convert + * @return the entity as a DTO + */ + protected D convertToDto(final E entity) { + return getModelMapper().map(entity, getDtoClass()); + } + + /** + * Converts a list of entities to a list of DTOs. + * + * @param entities the entities to convert + * @return the entities as DTOs + */ + protected List convertToDtos(final List entities) { + return entities.stream() + .map(this::convertToDto) + .toList(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/common/util/GraphQlUtil.java b/src/main/java/de/unistuttgart/iste/meitrex/common/util/GraphQlUtil.java new file mode 100644 index 0000000..532e180 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/common/util/GraphQlUtil.java @@ -0,0 +1,19 @@ +package de.unistuttgart.iste.meitrex.common.util; + +import lombok.NoArgsConstructor; +import org.intellij.lang.annotations.Language; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class GraphQlUtil { + + /** + * This method is used to mark a string as a GraphQl. + * This is useful for IntelliJ IDEA to provide syntax highlighting and code completion. + * + * @param graphQl the GraphQl string + * @return the GraphQl string + */ + public static String gql(@Language("GraphQl") final String graphQl) { + return graphQl; + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java new file mode 100644 index 0000000..ff747b9 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTest.java @@ -0,0 +1,220 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.exception.MeitrexNotFoundException; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import de.unistuttgart.iste.meitrex.common.service.AbstractCrudServiceTestImpl.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.Mockito.*; + +class AbstractCrudServiceTest { + + private AbstractCrudServiceTestImpl service; + private MeitrexRepository repository; + + @BeforeEach + public void setUp() { + repository = Mockito.mock(TestRepository.class); + service = new AbstractCrudServiceTestImpl(repository); + } + + @Test + void testGetAll() { + when(repository.findAll()).thenReturn(List.of(new TestEntity(1L, "Test"))); + + List result = service.getAll(); + + assertThat(result, hasSize(1)); + assertThat(result.getFirst().getId(), is(1L)); + assertThat(result.getFirst().getName(), is("Test")); + + verify(repository, times(1)).findAll(); + } + + @Test + void testFind() { + Long id = 1L; + when(repository.findById(id)).thenReturn(Optional.of(new TestEntity(id, "Test"))); + + Optional result = service.find(id); + + assertThat(result.isPresent(), is(true)); + assertThat(result.get().getId(), is(1L)); + assertThat(result.get().getName(), is("Test")); + + verify(repository, times(1)).findById(id); + } + + @Test + void testFindNotFound() { + Long id = 1L; + when(repository.findById(id)).thenReturn(Optional.empty()); + + Optional result = service.find(id); + + assertThat(result.isEmpty(), is(true)); + verify(repository, times(1)).findById(id); + } + + @Test + void testGetOrThrow() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenReturn(new TestEntity(id, "Test")); + + TestDto result = service.getOrThrow(id); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).findByIdOrThrow(id); + } + + @Test + void testGetOrThrowNotFound() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenThrow(new MeitrexNotFoundException("Entity not found.")); + + assertThrows(MeitrexNotFoundException.class, () -> service.getOrThrow(id)); + + verify(repository, times(1)).findByIdOrThrow(id); + } + + @Test + void testCreateEntity() { + TestEntity entity = new TestEntity(1L, "Test"); + when(repository.save(any())).thenAnswer(returnsFirstArg()); + + TestEntity result = service.createEntity(() -> entity); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(entity); + } + + @Test + void testCreateEntityFromInput() { + when(repository.save(any())).thenAnswer(invocation -> { + TestEntity entity = invocation.getArgument(0); + entity.setId(1L); + return entity; + }); + + TestEntity result = service.createEntity(new TestInputDto("Test")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(any()); + } + + @Test + void testCreate() { + TestEntity entity = new TestEntity(1L, "Test"); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.create(() -> entity); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(entity); + } + + @Test + void testCreateFromInput() { + when(repository.save(any())).thenAnswer(invocation -> { + TestEntity entity = invocation.getArgument(0); + entity.setId(1L); + return entity; + }); + + TestDto result = service.create(new TestInputDto("Test")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Test")); + + verify(repository, times(1)).save(any()); + } + + @Test + void testUpdate() { + Long id = 1L; + TestEntity entity = new TestEntity(id, "Test"); + + when(repository.findByIdOrThrow(id)).thenReturn(entity); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.update(id, e -> e.setName("Updated")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Updated")); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, times(1)).save(any()); + } + + @Test + void testUpdateNotFound() { + Long id = 1L; + when(repository.findByIdOrThrow(id)).thenThrow(new MeitrexNotFoundException("Entity not found.")); + + assertThrows(MeitrexNotFoundException.class, () -> service.update(id, e -> e.setName("Updated"))); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, never()).save(any()); + } + + @Test + void testUpdateFromInput() { + Long id = 1L; + TestEntity entity = new TestEntity(id, "Test"); + + when(repository.findByIdOrThrow(id)).thenReturn(entity); + when(repository.save(any())).thenReturn(entity); + + TestDto result = service.update(id, new TestInputDto("Updated")); + + assertThat(result.getId(), is(1L)); + assertThat(result.getName(), is("Updated")); + + verify(repository, times(1)).findByIdOrThrow(id); + verify(repository, times(1)).save(any()); + } + + @Test + void testDelete() { + Long id = 1L; + when(repository.existsById(id)).thenReturn(true); + + boolean result = service.delete(id); + + assertThat(result, is(true)); + + verify(repository, times(1)).existsById(id); + verify(repository, times(1)).deleteById(id); + } + + @Test + void testDeleteNotFound() { + Long id = 1L; + when(repository.existsById(id)).thenReturn(false); + + boolean result = service.delete(id); + + assertThat(result, is(false)); + + verify(repository, times(1)).existsById(id); + verify(repository, never()).deleteById(id); + } +} \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java new file mode 100644 index 0000000..84c86a5 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/common/service/AbstractCrudServiceTestImpl.java @@ -0,0 +1,119 @@ +package de.unistuttgart.iste.meitrex.common.service; + +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.modelmapper.ModelMapper; + +public class AbstractCrudServiceTestImpl + extends AbstractCrudService { + + private MeitrexRepository repository; + private ModelMapper modelMapper; + + public AbstractCrudServiceTestImpl(MeitrexRepository repository) { + this.repository = repository; + this.modelMapper = new ModelMapper(); + modelMapper.createTypeMap(TestInputDto.class, TestEntity.class) + .addMappings(mapper -> mapper.skip(TestEntity::setId)) + .addMappings(mapper -> mapper.map(TestInputDto::getInputName, TestEntity::setName)); + } + + @Override + protected Class getEntityClass() { + return TestEntity.class; + } + + @Override + protected Class getDtoClass() { + return TestDto.class; + } + + @Override + protected ModelMapper getModelMapper() { + return modelMapper; + } + + @Override + protected MeitrexRepository getRepository() { + return repository; + } + + public static class TestEntity implements IWithId { + private Long id; + private String name; + + public TestEntity() { + } + + public TestEntity(Long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static class TestDto { + private Long id; + private String name; + + public TestDto() { + } + + public TestDto(Long id, String name) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static class TestInputDto { + private String inputName; + + public TestInputDto(String inputName) { + this.inputName = inputName; + } + + public String getInputName() { + return inputName; + } + + public TestInputDto setInputName(String inputName) { + this.inputName = inputName; + return this; + } + } + + public interface TestRepository extends MeitrexRepository { + } +}