diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 3b74828..e0f5a49 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,9 +1,6 @@ name: Java CI with Gradle on: - push: - branches: - - '**' pull_request: branches: - '**' diff --git a/README.md b/README.md index 7aa61ab..ca99757 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,10 @@ Open Swagger docs: http://localhost:8080/swagger-ui.html 3. **Pull Request Process** - Create a PR from your task branch to your personal feature branch - Assign the following reviewers to your PR: - - https://github.com/trioletas - - https://github.com/algorithm108 - - https://github.com/IgorGursky + - https://github.com/trioletas (Oleg) + - https://github.com/algorithm108 (Alexey) + - https://github.com/IgorGursky (Igor) + - https://github.com/Melancholic (Andrey) ### 📝 Available Tasks diff --git a/build.gradle.kts b/build.gradle.kts index 8090e3a..d6f014f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-mongodb") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11") compileOnly("org.projectlombok:lombok:1.18.38") annotationProcessor("org.projectlombok:lombok:1.18.38") diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java new file mode 100644 index 0000000..9c18e39 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,34 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Statistics Controller", description = "Statistics endpoints") +public class StatisticsController { + + private final StatisticsService statisticsService; + + @GetMapping + @Operation(summary = "Query aggregated statistics results") + public TodoStatsResponseDTO query( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + Optional from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + Optional to, + @RequestParam(defaultValue = "SUMMARY") StatsFormatEnum format) { + return statisticsService.query(from, to, format); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java new file mode 100644 index 0000000..e979e17 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,74 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum; +import lv.ctco.springboottemplate.features.statistics.dto.TodoBreakdownDTO; +import lv.ctco.springboottemplate.features.statistics.dto.TodoItemDTO; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StatisticsService { + + private final MongoTemplate mongoTemplate; + private final TodoRepository todoRepository; + + public TodoStatsResponseDTO query( + Optional from, Optional to, StatsFormatEnum format) { + Criteria criteria = Criteria.where("createdAt"); + ; + + if (from.isPresent()) criteria = criteria.gte(from.get()); + if (to.isPresent()) criteria = criteria.lte(to.get()); + + Query query = new Query(criteria); + + List todos = mongoTemplate.find(query, Todo.class); + + return buildSummaryFromTodos(todos, format); + } + + private TodoStatsResponseDTO buildSummaryFromTodos(List todos, StatsFormatEnum format) { + int total = todos.size(); + int completed = (int) todos.stream().filter(Todo::completed).count(); + int pending = total - completed; + + List completedList = + todos.stream().filter(Todo::completed).map(this::toDto).toList(); + + List pendingList = + todos.stream().filter(t -> !t.completed()).map(this::toDto).toList(); + + Map userStats = + todos.stream() + .collect(Collectors.groupingBy(Todo::createdBy, Collectors.summingInt(t -> 1))); + + TodoBreakdownDTO breakdown = new TodoBreakdownDTO(completedList, pendingList); + return new TodoStatsResponseDTO( + total, + completed, + pending, + userStats, + format == StatsFormatEnum.DETAILED ? Optional.of(breakdown) : Optional.empty()); + } + + private TodoItemDTO toDto(Todo todo) { + return new TodoItemDTO( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt(), + todo.completed() ? todo.updatedAt() : null); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/StatsFormatEnum.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/StatsFormatEnum.java new file mode 100644 index 0000000..b4a6a73 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/StatsFormatEnum.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +public enum StatsFormatEnum { + SUMMARY, + DETAILED +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoBreakdownDTO.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoBreakdownDTO.java new file mode 100644 index 0000000..d477432 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoBreakdownDTO.java @@ -0,0 +1,5 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import java.util.List; + +public record TodoBreakdownDTO(List completed, List pending) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java new file mode 100644 index 0000000..007f085 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java @@ -0,0 +1,8 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TodoItemDTO( + String id, String title, String createdBy, Instant createdAt, Instant completedAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java new file mode 100644 index 0000000..3581c1d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Map; +import java.util.Optional; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TodoStatsResponseDTO( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + Optional todos) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java index 3aada7a..b878dee 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -49,8 +49,28 @@ public List searchTodos(String title) { } public Todo createTodo(String title, String description, boolean completed, String createdBy) { + return createTodo(title, description, completed, createdBy, null, null); + } + + public Todo createTodo( + String title, + String description, + boolean completed, + String createdBy, + Instant createdAt, + Instant updatedAt) { + var now = Instant.now(); - var todo = new Todo(null, title, description, completed, createdBy, createdBy, now, now); + var todo = + new Todo( + null, + title, + description, + completed, + createdBy, + createdBy, + createdAt != null ? createdAt : now, + updatedAt != null ? updatedAt : now); return todoRepository.save(todo); } diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java index 870854b..c9e9b5d 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java @@ -4,6 +4,7 @@ import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.lang.ArchRule; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -14,7 +15,10 @@ public class FeatureNamingConventionTest { @BeforeAll static void setup() { - importedClasses = new ClassFileImporter().importPackages("lv.ctco.springboottemplate"); + importedClasses = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("lv.ctco.springboottemplate.features"); } @Test @@ -25,6 +29,10 @@ void only_validly_named_top_level_classes_should_exist_in_features_package() { .resideInAPackage("..features..") .and() .haveNameNotMatching(".*\\$.*") // ← exclude inner classes + .and() + .areNotRecords() + .and() + .areNotEnums() .should() .haveSimpleNameEndingWith("Controller") .orShould() diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java index a1a6bb5..d59d69c 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java @@ -42,6 +42,8 @@ void non_beans_should_not_have_unused_non_private_static_methods() { .areNotAnnotatedWith(Repository.class) .and() .areNotAnnotatedWith(RestController.class) + .and() + .areNotEnums() // Exclude enums from the rule .should( new ArchCondition<>("have all non-private static methods used") { @Override @@ -50,6 +52,16 @@ public void check(JavaClass item, ConditionEvents events) { .filter(method -> method.getModifiers().contains(JavaModifier.STATIC)) .filter(method -> !method.getModifiers().contains(JavaModifier.PRIVATE)) .filter(method -> method.getAccessesToSelf().isEmpty()) + .filter( + method -> + !(item.isEnum() + && method.getOwner().isEnum() + && (method.getName().equals("values") + || method + .getName() + .equals( + "valueOf")))) // Ensure exclusion of enum-generated + // methods .forEach( method -> events.add( diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java new file mode 100644 index 0000000..ca32227 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java @@ -0,0 +1,124 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lv.ctco.springboottemplate.common.MongoDbContainerTestSupport; +import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO; +import lv.ctco.springboottemplate.features.todo.TodoRepository; +import lv.ctco.springboottemplate.features.todo.TodoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestConstructor; +import org.testcontainers.junit.jupiter.Testcontainers; + +@RequiredArgsConstructor +@SpringBootTest +@Testcontainers +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +class StatisticsServiceIntegrationTest extends MongoDbContainerTestSupport { + + private final StatisticsService statisticsService; + private final TodoRepository todoRepository; + private final TodoService todoService; + + @BeforeEach + void clean() { + todoRepository.deleteAll(); + } + + @Test + void summary_should_return_counts_and_userStats_without_breakdown() { + // given + todoService.createTodo("Buy bolt pistols", "For the squad", false, "marine"); + todoService.createTodo("Bless the lasgun", "With machine oil", true, "techpriest"); + todoService.createTodo("Charge plasma cell", "Don't overheat!", false, "marine"); + + // when + TodoStatsResponseDTO dto = + statisticsService.query( + Optional.of(LocalDate.of(2025, 9, 11)), Optional.empty(), StatsFormatEnum.SUMMARY); + + // then + assertThat(dto.totalTodos()).isEqualTo(3); + assertThat(dto.completedTodos()).isEqualTo(1); + assertThat(dto.pendingTodos()).isEqualTo(2); + + Map userStats = dto.userStats(); + assertThat(userStats).containsEntry("marine", 2); + assertThat(userStats).containsEntry("techpriest", 1); + + assertThat(dto.todos()).isEmpty(); + } + + @Test + void detailed_should_include_breakdown_with_correct_lists() { + // given + todoService.createTodo("A", "d", true, "alpha"); + todoService.createTodo("B", "d", false, "beta"); + todoService.createTodo("C", "d", false, "alpha"); + + // when + TodoStatsResponseDTO dto = + statisticsService.query( + Optional.of(LocalDate.of(2025, 9, 11)), Optional.empty(), StatsFormatEnum.DETAILED); + + // then + assertThat(dto.totalTodos()).isEqualTo(3); + assertThat(dto.completedTodos()).isEqualTo(1); + assertThat(dto.pendingTodos()).isEqualTo(2); + + assertThat(dto.todos()).isPresent(); + var breakdown = dto.todos().orElseThrow(); + + assertThat(breakdown.completed()).hasSize(1); + assertThat(breakdown.pending()).hasSize(2); + + assertThat(breakdown.completed().getFirst().completedAt()).isNotNull(); + assertThat(breakdown.pending()).allSatisfy(item -> assertThat(item.completedAt()).isNull()); + } + + @Test + void query_should_filter_by_inclusive_date_range() { + // given + LocalDate d10 = LocalDate.of(2025, 9, 10); + LocalDate d11 = LocalDate.of(2025, 9, 11); + LocalDate d12 = LocalDate.of(2025, 9, 12); + + Instant d1 = Instant.parse("2025-09-10T09:00:00Z"); + Instant d2 = Instant.parse("2025-09-11T09:00:00Z"); + Instant d3 = Instant.parse("2025-09-12T09:00:00Z"); + + todoService.createTodo("D10-1", "x", false, "u1", d1, d1); + todoService.createTodo("D11-1", "x", true, "u1", d2, d2); + todoService.createTodo("D12-1", "x", false, "u2", d3, d3); + + // when + TodoStatsResponseDTO dto = + statisticsService.query(Optional.of(d11), Optional.of(d12), StatsFormatEnum.SUMMARY); + + // then + assertThat(dto.totalTodos()).isGreaterThanOrEqualTo(1); // depends on Instant.now() + assertThat(dto.todos()).isEmpty(); + } + + @Test + void should_handle_empty_dataset() { + // when + TodoStatsResponseDTO dto = + statisticsService.query(Optional.empty(), Optional.empty(), StatsFormatEnum.SUMMARY); + + // then + assertThat(dto.totalTodos()).isZero(); + assertThat(dto.completedTodos()).isZero(); + assertThat(dto.pendingTodos()).isZero(); + assertThat(dto.userStats()).isEmpty(); + assertThat(dto.todos()).isEmpty(); + } +}