From babbd8536cdcae4f942df3f55b513fed22d2b572 Mon Sep 17 00:00:00 2001 From: Alexey Shnyakin Date: Wed, 16 Jul 2025 17:29:24 +0300 Subject: [PATCH 1/6] feat(ci/cd): adjust branch build pattern (#29) --- .github/workflows/gradle.yml | 6 ++++-- README.md | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 5b405fd..345e96d 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -2,9 +2,11 @@ name: Java CI with Gradle on: push: - branches: [ "main" ] + branches: + - '**' pull_request: - branches: [ "main" ] + branches: + - '**' jobs: build: 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 From a64e97124a0a08fcb242af5c00caa5a187db6fe3 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 29 Aug 2025 01:44:45 +0000 Subject: [PATCH 2/6] chore(deps): update dependency org.springdoc:springdoc-openapi-starter-webmvc-ui to v2.8.11 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 464ea11..d62de8b 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") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.junit.jupiter:junit-jupiter:5.7.1") testImplementation("com.tngtech.archunit:archunit:1.4.1") From 23d07a19865129693b1da7494de25d8b4d7b06e3 Mon Sep 17 00:00:00 2001 From: Algorithm108 Date: Tue, 2 Sep 2025 21:52:37 +0300 Subject: [PATCH 3/6] feat(tests): change archunit tests behaviour to allow enums and records --- .github/workflows/gradle.yml | 3 --- .../architecture/FeatureNamingConventionTest.java | 10 +++++++++- .../architecture/UnusedStaticMethodsTest.java | 12 ++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 345e96d..9c96f4e 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/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( From 03a24f22309a3d27b15a0bd3b3756ae838fb1f91 Mon Sep 17 00:00:00 2001 From: victor Date: Fri, 5 Sep 2025 18:28:09 +0300 Subject: [PATCH 4/6] chore: WIP, working stats, without details --- .../statistics/StatisticsController.java | 34 ++++++++++++ .../statistics/StatisticsService.java | 54 +++++++++++++++++++ .../statistics/dto/StatsFormatEnum.java | 6 +++ .../statistics/dto/TodoBreakdownDTO.java | 5 ++ .../features/statistics/dto/TodoItemDTO.java | 13 +++++ .../statistics/dto/TodoStatsResponseDTO.java | 14 +++++ 6 files changed, 126 insertions(+) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/StatsFormatEnum.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoBreakdownDTO.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java 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..c6fb8e9 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,54 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.LocalDate; +import java.time.LocalTime; +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.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); // <- We'll start here + } + + private TodoStatsResponseDTO buildSummaryFromTodos(List todos) { + int total = todos.size(); + int completed = (int) todos.stream().filter(Todo::completed).count(); + int pending = total - completed; + + Map userStats = + todos.stream() + .collect(Collectors.groupingBy(Todo::createdBy, Collectors.summingInt(t -> 1))); + + return new TodoStatsResponseDTO( + total, completed, pending, userStats, 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..14060f0 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java @@ -0,0 +1,13 @@ +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 // only present for completed todos + ) {} 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..8a56f7a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java @@ -0,0 +1,14 @@ +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, // custom user input stats + Optional todos // will be empty for summary response + ) {} From f1610c45e0ec60cc65cec731a8494e075c0f16ec Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 15 Sep 2025 21:16:46 +0300 Subject: [PATCH 5/6] feat: finish task 2 --- .../statistics/StatisticsService.java | 34 ++++- .../features/statistics/dto/TodoItemDTO.java | 2 +- .../statistics/dto/TodoStatsResponseDTO.java | 4 +- .../features/todo/TodoService.java | 30 ++++- .../StatisticsServiceIntegrationTest.java | 123 ++++++++++++++++++ 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java index c6fb8e9..62e50be 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -9,6 +9,8 @@ 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; @@ -26,7 +28,8 @@ public class StatisticsService { public TodoStatsResponseDTO query( Optional from, Optional to, StatsFormatEnum format) { - Criteria criteria = Criteria.where("createdAt");; + Criteria criteria = Criteria.where("createdAt"); + ; if (from.isPresent()) criteria = criteria.gte(from.get()); if (to.isPresent()) criteria = criteria.lte(to.get()); @@ -35,20 +38,43 @@ public TodoStatsResponseDTO query( List todos = mongoTemplate.find(query, Todo.class); - return buildSummaryFromTodos(todos); // <- We'll start here + return buildSummaryFromTodos(todos, format); } - private TodoStatsResponseDTO buildSummaryFromTodos(List todos) { + 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, null + 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/TodoItemDTO.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java index 14060f0..b2c847e 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java @@ -9,5 +9,5 @@ public record TodoItemDTO( String title, String createdBy, Instant createdAt, - Instant completedAt // only present for completed todos + 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 index 8a56f7a..3976f9a 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java @@ -9,6 +9,6 @@ public record TodoStatsResponseDTO( int totalTodos, int completedTodos, int pendingTodos, - Map userStats, // custom user input stats - Optional todos // will be empty for summary response + 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..cc4db6d 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -48,11 +48,31 @@ public List searchTodos(String title) { return todoRepository.findByTitleContainingIgnoreCase(title); } - public Todo createTodo(String title, String description, boolean completed, String createdBy) { - var now = Instant.now(); - var todo = new Todo(null, title, description, completed, createdBy, createdBy, now, now); - return todoRepository.save(todo); - } + 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, + createdAt != null ? createdAt : now, + updatedAt != null ? updatedAt : now + ); + return todoRepository.save(todo); + } public Optional updateTodo( String id, String title, String description, boolean completed, String updatedBy) { 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..c03ced7 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java @@ -0,0 +1,123 @@ +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(); + } +} From 049cc341e4323bbca6f90a9a76aea28be55e4cc2 Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 15 Sep 2025 21:20:22 +0300 Subject: [PATCH 6/6] spottless --- .../statistics/StatisticsService.java | 84 ++++---- .../features/statistics/dto/TodoItemDTO.java | 7 +- .../statistics/dto/TodoStatsResponseDTO.java | 3 +- .../features/todo/TodoService.java | 46 ++--- .../StatisticsServiceIntegrationTest.java | 193 +++++++++--------- 5 files changed, 161 insertions(+), 172 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java index 62e50be..e979e17 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -1,12 +1,10 @@ package lv.ctco.springboottemplate.features.statistics; import java.time.LocalDate; -import java.time.LocalTime; 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; @@ -23,58 +21,54 @@ @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()); + private final MongoTemplate mongoTemplate; + private final TodoRepository todoRepository; - Query query = new Query(criteria); + public TodoStatsResponseDTO query( + Optional from, Optional to, StatsFormatEnum format) { + Criteria criteria = Criteria.where("createdAt"); + ; - List todos = mongoTemplate.find(query, Todo.class); + if (from.isPresent()) criteria = criteria.gte(from.get()); + if (to.isPresent()) criteria = criteria.lte(to.get()); - return buildSummaryFromTodos(todos, format); - } + Query query = new Query(criteria); - 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 todos = mongoTemplate.find(query, Todo.class); - List completedList = todos.stream() - .filter(Todo::completed) - .map(this::toDto) - .toList(); + return buildSummaryFromTodos(todos, format); + } - List pendingList = todos.stream() - .filter(t -> !t.completed()) - .map(this::toDto) - .toList(); + private TodoStatsResponseDTO buildSummaryFromTodos(List todos, StatsFormatEnum format) { + int total = todos.size(); + int completed = (int) todos.stream().filter(Todo::completed).count(); + int pending = total - completed; - Map userStats = - todos.stream() - .collect(Collectors.groupingBy(Todo::createdBy, Collectors.summingInt(t -> 1))); + List completedList = + todos.stream().filter(Todo::completed).map(this::toDto).toList(); + List pendingList = + todos.stream().filter(t -> !t.completed()).map(this::toDto).toList(); - TodoBreakdownDTO breakdown = new TodoBreakdownDTO(completedList, pendingList); - return new TodoStatsResponseDTO( - total, completed, pending, userStats, format == StatsFormatEnum.DETAILED ? Optional.of(breakdown) : Optional.empty() - ); - } + Map userStats = + todos.stream() + .collect(Collectors.groupingBy(Todo::createdBy, Collectors.summingInt(t -> 1))); - private TodoItemDTO toDto(Todo todo) { - return new TodoItemDTO( - todo.id(), - todo.title(), - todo.createdBy(), - todo.createdAt(), - todo.completed() ? todo.updatedAt() : null - ); - } + 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/TodoItemDTO.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java index b2c847e..007f085 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoItemDTO.java @@ -5,9 +5,4 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record TodoItemDTO( - String id, - String title, - String createdBy, - Instant createdAt, - Instant completedAt - ) {} + 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 index 3976f9a..3581c1d 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsResponseDTO.java @@ -10,5 +10,4 @@ public record TodoStatsResponseDTO( int completedTodos, int pendingTodos, Map userStats, - Optional todos - ) {} + 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 cc4db6d..b878dee 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -48,31 +48,31 @@ public List searchTodos(String title) { return todoRepository.findByTitleContainingIgnoreCase(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) { + return createTodo(title, description, completed, createdBy, null, null); + } - public Todo createTodo( - String title, - String description, - boolean completed, - String createdBy, - Instant createdAt, - Instant updatedAt) { + 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, - createdAt != null ? createdAt : now, - updatedAt != null ? updatedAt : now - ); - return todoRepository.save(todo); - } + var now = Instant.now(); + var todo = + new Todo( + null, + title, + description, + completed, + createdBy, + createdBy, + createdAt != null ? createdAt : now, + updatedAt != null ? updatedAt : now); + return todoRepository.save(todo); + } public Optional updateTodo( String id, String title, String description, boolean completed, String updatedBy) { diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java index c03ced7..ca32227 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java @@ -6,7 +6,6 @@ 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; @@ -25,99 +24,101 @@ @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(); - } + 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(); + } }