From 5d93d1b972f47beaf8c9e59085841d31c356a0eb Mon Sep 17 00:00:00 2001 From: margarita-pashnina Date: Thu, 28 Aug 2025 08:04:54 +0300 Subject: [PATCH 1/7] feat(task-2): Create the "Statistics" Feature --- .../features/statistics/Statistics.java | 12 ++ .../statistics/StatisticsController.java | 46 +++++ .../features/statistics/StatisticsFormat.java | 17 ++ .../statistics/StatisticsFormatValidator.java | 13 ++ .../statistics/StatisticsInputParams.java | 10 + .../statistics/StatisticsService.java | 152 +++++++++++++++ .../features/statistics/StatisticsTodo.java | 12 ++ .../FeatureNamingConventionTest.java | 2 + .../StatisticsControllerIntegrationTest.java | 183 ++++++++++++++++++ 9 files changed, 447 insertions(+) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormat.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.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/StatisticsTodo.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java new file mode 100644 index 0000000..ab8b397 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record Statistics( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + Optional>> todos) {} 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..10d8955 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,46 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.ConstraintViolationException; +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Statistics Controller", description = "Statistics endpoint") +@Validated +public class StatisticsController { + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation(summary = "Get todo statistics") + public ResponseEntity getStatistics( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional to, + @RequestParam @StatisticsFormat String format) { + return ResponseEntity.ok(statisticsService.getStatisticsSummary(from, to, format)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormat.java new file mode 100644 index 0000000..dac392e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormat.java @@ -0,0 +1,17 @@ +package lv.ctco.springboottemplate.features.statistics; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = StatisticsFormatValidator.class) +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface StatisticsFormat { + String message() default "Either 'summary' or 'detailed' must be provided for format"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java new file mode 100644 index 0000000..dfaa566 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.statistics; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.stereotype.Component; + +@Component +public class StatisticsFormatValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value.equals("summary") || value.equals("detailed"); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java new file mode 100644 index 0000000..0ca59f2 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.format.annotation.DateTimeFormat; + +public record StatisticsInputParams( + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional from, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional to, + @StatisticsFormat String 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..5f72cbe --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,152 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.core.query.Criteria.where; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.*; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Service; + +@Service +public class StatisticsService { + private final MongoTemplate mongoTemplate; + + public StatisticsService(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public Statistics getStatisticsSummary( + Optional from, Optional to, String format) { + Document result = getTodosStatisticsAggregation(from, to, format).getUniqueMappedResult(); + + int totalTodos = getTodoCount(result, "totalTodos"); + int completedTodos = getTodoCount(result, "completedTodos"); + int pendingTodos = getTodoCount(result, "pendingTodos"); + + Map userStats = getUserStats(result); + + if (format.equals("summary")) { + return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.empty()); + } + + Map> todos = getStatisticsTodos(result); + + return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.of(todos)); + } + + private AggregationResults getTodosStatisticsAggregation( + Optional from, Optional to, String format) { + MatchOperation matchTodosInRange = matchDateRange(from, to); + MatchOperation matchCompletedTodos = match(where("completed").is(true)); + MatchOperation matchPendingTodos = match(where("completed").is(false)); + + GroupOperation countTodos = group().count().as("count"); + GroupOperation countTodosPerUser = group("createdBy").count().as("count"); + + ProjectionOperation projectPendingFields = project("id", "title", "createdBy", "createdAt"); + ProjectionOperation projectCompletedFields = + projectPendingFields.andExpression("updatedAt").as("completedAt"); + + FacetOperation summaryFacet = + facet(countTodos) + .as("totalTodos") + .and(matchCompletedTodos, countTodos) + .as("completedTodos") + .and(matchPendingTodos, countTodos) + .as("pendingTodos") + .and(countTodosPerUser) + .as("userStats"); + + FacetOperation detailsFacet = + facet(countTodos) + .as("totalTodos") + .and(matchCompletedTodos, countTodos) + .as("completedTodos") + .and(matchPendingTodos, countTodos) + .as("pendingTodos") + .and(countTodosPerUser) + .as("userStats") + .and(matchPendingTodos, projectPendingFields) + .as("pending") + .and(matchCompletedTodos, projectCompletedFields) + .as("completed"); + + Aggregation summaryAggregation = newAggregation(matchTodosInRange, summaryFacet); + + Aggregation detailedAggregation = newAggregation(matchTodosInRange, detailsFacet); + + if (Objects.equals(format, "summary")) { + return this.mongoTemplate.aggregate(summaryAggregation, "todos", Document.class); + } else { + return this.mongoTemplate.aggregate(detailedAggregation, "todos", Document.class); + } + } + + private MatchOperation matchDateRange(Optional from, Optional to) { + Criteria criteria = + Stream.of( + from.map(f -> where("createdAt").gte(f.atStartOfDay())), + to.map(t -> where("createdAt").lte(t.atTime(LocalTime.MAX)))) + .flatMap(Optional::stream) + .reduce( + new Criteria(), + (c1, c2) -> + c1.getCriteriaObject().isEmpty() ? c2 : new Criteria().andOperator(c1, c2)); + return Aggregation.match(criteria); + } + + private int getTodoCount(Document result, String key) { + List totalTodosDocument = result.getList(key, Document.class); + if (totalTodosDocument.isEmpty()) { + return 0; + } + return (int) totalTodosDocument.getFirst().get("count"); + } + + private Map getUserStats(Document result) { + List userStatsDocument = result.getList("userStats", Document.class); + return userStatsDocument.stream() + .collect( + Collectors.toMap( + stat -> (String) stat.get("_id"), stat -> (Integer) stat.get("count"))); + } + + private Map> getStatisticsTodos(Document result) { + List pendingDocument = result.getList("pending", Document.class); + List pending = + pendingDocument.stream() + .map( + item -> + new StatisticsTodo( + item.get("_id").toString(), + item.get("title").toString(), + item.get("createdBy").toString(), + ((Date) item.get("createdAt")).toInstant(), + Optional.empty())) + .toList(); + + List completedDocument = result.getList("completed", Document.class); + List completed = + completedDocument.stream() + .map( + item -> + new StatisticsTodo( + item.get("_id").toString(), + item.get("title").toString(), + item.get("createdBy").toString(), + ((Date) item.get("createdAt")).toInstant(), + Optional.ofNullable(((Date) item.get("completedAt")).toInstant()))) + .toList(); + Map> todos = new HashMap<>(); + todos.put("completed", completed); + todos.put("pending", pending); + return todos; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsTodo.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsTodo.java new file mode 100644 index 0000000..69e0d66 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsTodo.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.annotation.Id; + +public record StatisticsTodo( + @Id String id, + String title, + String createdBy, + Instant createdAt, + Optional completedAt) {} diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java index 870854b..b9f37c6 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/FeatureNamingConventionTest.java @@ -35,6 +35,8 @@ void only_validly_named_top_level_classes_should_exist_in_features_package() { .haveSimpleNameStartingWith("Todo") .orShould() .haveSimpleNameStartingWith("Greeting") + .orShould() + .haveSimpleNameStartingWith("Statistics") .because( """ The 'features' package should contain only top-level components like: diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..5a56bdc --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,183 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.mockito.MockedStatic; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestConstructor; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +public class StatisticsControllerIntegrationTest { + @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + } + + private final StatisticsController statisticsController; + private final TodoRepository todoRepository; + private final TodoService todoService; + + public StatisticsControllerIntegrationTest( + StatisticsController statisticsController, + TodoService todoService, + TodoRepository todoRepository) { + this.statisticsController = statisticsController; + this.todoRepository = todoRepository; + this.todoService = todoService; + } + + @BeforeEach + void clean() { + todoRepository.deleteAll(); + } + + @Test + void should_filter_by_from_to() { + // given + todoService.createTodo("Buy bolt pistols", "For the squad", false, "marine"); + + Instant fixedInstant = Instant.now().minusSeconds(60 * 60 * 24); + MockedStatic mockInstant = mockStatic(Instant.class); + mockInstant.when(Instant::now).thenReturn(fixedInstant); + todoService.createTodo("Bless the lasgun", "With machine oil", true, "techpriest"); + + mockInstant.close(); + + Instant fixedInstant2 = Instant.now().minusSeconds(3 * 60 * 60 * 24); + MockedStatic mockInstant2 = mockStatic(Instant.class); + mockInstant2.when(Instant::now).thenReturn(fixedInstant2); + todoService.createTodo("Charge plasma cell", "Don't overheat!", false, "marine"); + mockInstant2.close(); + // when + ResponseEntity response = + statisticsController.getStatistics( + Optional.of(LocalDate.now().minusDays(2)), + Optional.of(LocalDate.now().minusDays(1)), + "summary"); + + // then + assertThat(response.getBody().totalTodos()).isEqualTo(1); + } + + @Test + void should_filter_by_from() { + // given + todoService.createTodo("Buy bolt pistols", "For the squad", false, "marine"); + + Instant fixedInstant = Instant.now().minusSeconds(60 * 60 * 24); + MockedStatic mockInstant = mockStatic(Instant.class); + mockInstant.when(Instant::now).thenReturn(fixedInstant); + todoService.createTodo("Bless the lasgun", "With machine oil", true, "techpriest"); + + mockInstant.close(); + + Instant fixedInstant2 = Instant.now().minusSeconds(3 * 60 * 60 * 24); + MockedStatic mockInstant2 = mockStatic(Instant.class); + mockInstant2.when(Instant::now).thenReturn(fixedInstant2); + todoService.createTodo("Charge plasma cell", "Don't overheat!", false, "marine"); + mockInstant2.close(); + // when + ResponseEntity response = + statisticsController.getStatistics( + Optional.of(LocalDate.now().minusDays(2)), Optional.empty(), "summary"); + + // then + assertThat(response.getBody().totalTodos()).isEqualTo(2); + } + + @Test + void should_filter_by_to() { + // given + todoService.createTodo("Buy bolt pistols", "For the squad", false, "marine"); + + Instant fixedInstant = Instant.now().minusSeconds(60 * 60 * 24); + MockedStatic mockInstant = mockStatic(Instant.class); + mockInstant.when(Instant::now).thenReturn(fixedInstant); + todoService.createTodo("Bless the lasgun", "With machine oil", true, "techpriest"); + + mockInstant.close(); + + Instant fixedInstant2 = Instant.now().minusSeconds(3 * 60 * 60 * 24); + MockedStatic mockInstant2 = mockStatic(Instant.class); + mockInstant2.when(Instant::now).thenReturn(fixedInstant2); + todoService.createTodo("Charge plasma cell", "Don't overheat!", false, "marine"); + mockInstant2.close(); + // when + ResponseEntity response = + statisticsController.getStatistics( + Optional.empty(), Optional.of(LocalDate.now().minusDays(2)), "summary"); + + // then + assertThat(response.getBody().totalTodos()).isEqualTo(1); + } + + @Test + void should_return_summary_fields() { + // 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"); + + Map userStats = new HashMap<>(); + userStats.put("marine", 2); + userStats.put("techpriest", 1); + + // when + ResponseEntity response = + statisticsController.getStatistics(Optional.empty(), Optional.empty(), "summary"); + + // then + assertThat(response.getBody().totalTodos()).isEqualTo(3); + assertThat(response.getBody().completedTodos()).isEqualTo(1); + assertThat(response.getBody().pendingTodos()).isEqualTo(2); + assertThat(response.getBody().userStats()).isEqualTo(userStats); + } + + @Test + void should_return_detailed_fields() { + // 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"); + + Map userStats = new HashMap<>(); + userStats.put("marine", 2); + userStats.put("techpriest", 1); + + // when + ResponseEntity response = + statisticsController.getStatistics(Optional.empty(), Optional.empty(), "detailed"); + Map> todos = response.getBody().todos().get(); + + // then + assertThat(response.getBody().totalTodos()).isEqualTo(3); + assertThat(response.getBody().completedTodos()).isEqualTo(1); + assertThat(response.getBody().pendingTodos()).isEqualTo(2); + assertThat(response.getBody().userStats()).isEqualTo(userStats); + assertThat(todos.get("completed").size()).isEqualTo(1); + assertThat(todos.get("pending").size()).isEqualTo(2); + assertThat(todos.get("completed").get(0).title()).isEqualTo("Bless the lasgun"); + assertThat(todos.get("pending").get(0).title()).isEqualTo("Buy bolt pistols"); + } +} From e807d65fd1ed783db0e478b853b898a28c0c8b9d Mon Sep 17 00:00:00 2001 From: margarita-pashnina Date: Thu, 28 Aug 2025 08:06:14 +0300 Subject: [PATCH 2/7] feat(task-2): Create the "Statistics" Feature --- .../features/statistics/StatisticsInputParams.java | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java deleted file mode 100644 index 0ca59f2..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsInputParams.java +++ /dev/null @@ -1,10 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics; - -import java.time.LocalDate; -import java.util.Optional; -import org.springframework.format.annotation.DateTimeFormat; - -public record StatisticsInputParams( - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional from, - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional to, - @StatisticsFormat String format) {} From e085e39f52c9ce6e77195dea699078445b16f92a Mon Sep 17 00:00:00 2001 From: "margarita.pashnina" Date: Thu, 28 Aug 2025 09:29:30 +0300 Subject: [PATCH 3/7] test fix --- .../architecture/UnusedMethodsTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java index 9a996ef..8f7e09e 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java @@ -42,6 +42,8 @@ public void check(JavaClass item, ConditionEvents events) { item.getMethods().stream() .filter(method -> !isSpringLifecycleMethod(method)) .filter(method -> method.getAccessesToSelf().isEmpty()) + // added to fix test, there may be a better way to handle this though + .filter(method -> !isValidatorIsValidMethod(item, method)) .forEach( method -> events.add( @@ -59,6 +61,13 @@ private boolean isSpringLifecycleMethod(JavaMethod method) { || method.getName().equals("afterPropertiesSet") || method.getName().startsWith("set"); } + + private boolean isValidatorIsValidMethod(JavaClass classItem, JavaMethod method) { + return method.getName().equals("isValid") + && classItem.getAllRawInterfaces().stream() + .anyMatch( + i -> i.getName().equals("jakarta.validation.ConstraintValidator")); + } }) .because( "All methods in Spring beans must be used somewhere in the codebase. " From a6016bb84aaddd53942eed202f83effa9b796b81 Mon Sep 17 00:00:00 2001 From: "margarita.pashnina" Date: Thu, 28 Aug 2025 09:34:53 +0300 Subject: [PATCH 4/7] spotless --- .../ctco/springboottemplate/architecture/UnusedMethodsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java index 8f7e09e..fbb9008 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java @@ -42,7 +42,7 @@ public void check(JavaClass item, ConditionEvents events) { item.getMethods().stream() .filter(method -> !isSpringLifecycleMethod(method)) .filter(method -> method.getAccessesToSelf().isEmpty()) - // added to fix test, there may be a better way to handle this though + // added to fix test, there may be a better way to handle this though .filter(method -> !isValidatorIsValidMethod(item, method)) .forEach( method -> From 44503ae5805aae6d9924e4bd8e13e71a7acee8cb Mon Sep 17 00:00:00 2001 From: margarita-pashnina Date: Mon, 1 Sep 2025 15:14:02 +0300 Subject: [PATCH 5/7] comments fix --- .../features/statistics/Statistics.java | 14 +++++++++----- .../statistics/StatisticsController.java | 18 +++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java index ab8b397..04e1590 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java @@ -1,12 +1,16 @@ package lv.ctco.springboottemplate.features.statistics; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.List; import java.util.Map; import java.util.Optional; +@JsonInclude(JsonInclude.Include.NON_ABSENT) public record Statistics( - int totalTodos, - int completedTodos, - int pendingTodos, - Map userStats, - Optional>> todos) {} + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + Optional>> todos) { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java index 10d8955..c9c1eaa 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -1,6 +1,8 @@ package lv.ctco.springboottemplate.features.statistics; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.ConstraintViolationException; import java.time.LocalDate; @@ -26,9 +28,19 @@ public StatisticsController(StatisticsService statisticsService) { @GetMapping @Operation(summary = "Get todo statistics") public ResponseEntity getStatistics( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional from, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Optional to, - @RequestParam @StatisticsFormat String format) { + @Parameter(description = "Start date. Format: YYYY-MM-DD", example = "2023-01-01") + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + Optional from, + @Parameter(description = "End date. Format: YYYY-MM-DD", example = "2023-12-31") + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + Optional to, + @Parameter(description = "Response format", example = "summary") + @Schema(allowableValues = {"summary", "detailed"}) + @RequestParam + @StatisticsFormat + String format) { return ResponseEntity.ok(statisticsService.getStatisticsSummary(from, to, format)); } From 43866436fa432639346348a6c6f226d3ad3a916b Mon Sep 17 00:00:00 2001 From: margarita-pashnina Date: Mon, 1 Sep 2025 15:17:17 +0300 Subject: [PATCH 6/7] spotless fix --- .../features/statistics/Statistics.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java index 04e1590..d7597ad 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/Statistics.java @@ -1,16 +1,14 @@ package lv.ctco.springboottemplate.features.statistics; import com.fasterxml.jackson.annotation.JsonInclude; - import java.util.List; import java.util.Map; import java.util.Optional; @JsonInclude(JsonInclude.Include.NON_ABSENT) public record Statistics( - int totalTodos, - int completedTodos, - int pendingTodos, - Map userStats, - Optional>> todos) { -} + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + Optional>> todos) {} From 4611cd56de47e775cb6914ba44e7af20fc2e1d18 Mon Sep 17 00:00:00 2001 From: margarita-pashnina Date: Fri, 12 Sep 2025 10:18:08 +0300 Subject: [PATCH 7/7] comment fixes --- .../common/GlobalExceptionHandler.java | 24 ++++++ .../statistics/StatisticsController.java | 24 ++---- .../statistics/StatisticsFormatValidator.java | 2 +- .../statistics/StatisticsFormats.java | 6 ++ .../statistics/StatisticsService.java | 82 +++++++++++-------- .../architecture/UnusedStaticMethodsTest.java | 3 + 6 files changed, 87 insertions(+), 54 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/common/GlobalExceptionHandler.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormats.java diff --git a/src/main/java/lv/ctco/springboottemplate/common/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..2fba4bf --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/common/GlobalExceptionHandler.java @@ -0,0 +1,24 @@ +package lv.ctco.springboottemplate.common; + +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java index c9c1eaa..87540fc 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -4,15 +4,15 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.ConstraintViolationException; import java.time.LocalDate; import java.util.Optional; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +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; @RestController @RequestMapping("/api/statistics") @@ -41,18 +41,8 @@ public ResponseEntity getStatistics( @RequestParam @StatisticsFormat String format) { - return ResponseEntity.ok(statisticsService.getStatisticsSummary(from, to, format)); - } - - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException( - ConstraintViolationException ex) { - return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity handleMethodArgumentTypeMismatchException( - MethodArgumentTypeMismatchException ex) { - return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + var statisticsFormat = + "summary".equals(format) ? StatisticsFormats.SUMMARY : StatisticsFormats.DETAILED; + return ResponseEntity.ok(statisticsService.getStatistics(from, to, statisticsFormat)); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java index dfaa566..cfa541f 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormatValidator.java @@ -8,6 +8,6 @@ public class StatisticsFormatValidator implements ConstraintValidator { @Override public boolean isValid(String value, ConstraintValidatorContext context) { - return value.equals("summary") || value.equals("detailed"); + return "summary".equals(value) || "detailed".equals(value); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormats.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormats.java new file mode 100644 index 0000000..9edca5d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsFormats.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics; + +public enum StatisticsFormats { + SUMMARY, + DETAILED +} 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 5f72cbe..513a3f6 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -22,9 +22,13 @@ public StatisticsService(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } - public Statistics getStatisticsSummary( - Optional from, Optional to, String format) { - Document result = getTodosStatisticsAggregation(from, to, format).getUniqueMappedResult(); + public Statistics getStatistics( + Optional from, Optional to, StatisticsFormats format) { + boolean isSummary = StatisticsFormats.SUMMARY.equals(format); + Document result = + isSummary + ? getSummaryStatisticsAggregation(from, to).getUniqueMappedResult() + : getDetailedStatisticsAggregation(from, to).getUniqueMappedResult(); int totalTodos = getTodoCount(result, "totalTodos"); int completedTodos = getTodoCount(result, "completedTodos"); @@ -32,7 +36,7 @@ public Statistics getStatisticsSummary( Map userStats = getUserStats(result); - if (format.equals("summary")) { + if (isSummary) { return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.empty()); } @@ -41,52 +45,46 @@ public Statistics getStatisticsSummary( return new Statistics(totalTodos, completedTodos, pendingTodos, userStats, Optional.of(todos)); } - private AggregationResults getTodosStatisticsAggregation( - Optional from, Optional to, String format) { - MatchOperation matchTodosInRange = matchDateRange(from, to); - MatchOperation matchCompletedTodos = match(where("completed").is(true)); - MatchOperation matchPendingTodos = match(where("completed").is(false)); - - GroupOperation countTodos = group().count().as("count"); - GroupOperation countTodosPerUser = group("createdBy").count().as("count"); - + private AggregationResults getDetailedStatisticsAggregation( + Optional from, Optional to) { ProjectionOperation projectPendingFields = project("id", "title", "createdBy", "createdAt"); ProjectionOperation projectCompletedFields = projectPendingFields.andExpression("updatedAt").as("completedAt"); - FacetOperation summaryFacet = - facet(countTodos) - .as("totalTodos") - .and(matchCompletedTodos, countTodos) - .as("completedTodos") - .and(matchPendingTodos, countTodos) - .as("pendingTodos") - .and(countTodosPerUser) - .as("userStats"); - FacetOperation detailsFacet = - facet(countTodos) + facet(countTodos()) .as("totalTodos") - .and(matchCompletedTodos, countTodos) + .and(matchTodosByCompletedState(true), countTodos()) .as("completedTodos") - .and(matchPendingTodos, countTodos) + .and(matchTodosByCompletedState(false), countTodos()) .as("pendingTodos") - .and(countTodosPerUser) + .and(countTodosPerUser()) .as("userStats") - .and(matchPendingTodos, projectPendingFields) + .and(matchTodosByCompletedState(false), projectPendingFields) .as("pending") - .and(matchCompletedTodos, projectCompletedFields) + .and(matchTodosByCompletedState(true), projectCompletedFields) .as("completed"); - Aggregation summaryAggregation = newAggregation(matchTodosInRange, summaryFacet); + Aggregation detailedAggregation = newAggregation(matchDateRange(from, to), detailsFacet); - Aggregation detailedAggregation = newAggregation(matchTodosInRange, detailsFacet); + return this.mongoTemplate.aggregate(detailedAggregation, "todos", Document.class); + } - if (Objects.equals(format, "summary")) { - return this.mongoTemplate.aggregate(summaryAggregation, "todos", Document.class); - } else { - return this.mongoTemplate.aggregate(detailedAggregation, "todos", Document.class); - } + private AggregationResults getSummaryStatisticsAggregation( + Optional from, Optional to) { + FacetOperation summaryFacet = + facet(countTodos()) + .as("totalTodos") + .and(matchTodosByCompletedState(true), countTodos()) + .as("completedTodos") + .and(matchTodosByCompletedState(false), countTodos()) + .as("pendingTodos") + .and(countTodosPerUser()) + .as("userStats"); + + Aggregation summaryAggregation = newAggregation(matchDateRange(from, to), summaryFacet); + + return this.mongoTemplate.aggregate(summaryAggregation, "todos", Document.class); } private MatchOperation matchDateRange(Optional from, Optional to) { @@ -102,6 +100,18 @@ private MatchOperation matchDateRange(Optional from, Optional totalTodosDocument = result.getList(key, Document.class); if (totalTodosDocument.isEmpty()) { diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java index a1a6bb5..cc7adb7 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedStaticMethodsTest.java @@ -42,6 +42,9 @@ void non_beans_should_not_have_unused_non_private_static_methods() { .areNotAnnotatedWith(Repository.class) .and() .areNotAnnotatedWith(RestController.class) + // Enum.valueOf is static and public so it fails the test + .and() + .areNotAssignableTo(Enum.class) .should( new ArchCondition<>("have all non-private static methods used") { @Override