From 93fd9c21f8413cd2ec821f3b347cfa94aeef2d76 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Mon, 13 Oct 2025 14:50:57 +0300 Subject: [PATCH 01/16] Task 2 - basic parameter validation, tests --- gradlew | 0 .../config/ResponseFormatConverter.java | 13 ++++++ .../statistics/ExceptionResponse.java | 17 ++++++++ .../statistics/GlobalExceptionHandler.java | 16 +++++++ .../features/statistics/ResponseFormat.java | 16 +++++++ .../statistics/StatisticsController.java | 33 ++++++++++++++ .../statistics/StatisticsControllerTest.java | 43 +++++++++++++++++++ 7 files changed, 138 insertions(+) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java b/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java new file mode 100644 index 0000000..eca22d1 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.config; + +import lv.ctco.springboottemplate.features.statistics.ResponseFormat; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class ResponseFormatConverter implements Converter { + @Override + public ResponseFormat convert(String value) { + return ResponseFormat.valueOf(value.toUpperCase()); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java new file mode 100644 index 0000000..59bed42 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java @@ -0,0 +1,17 @@ +package lv.ctco.springboottemplate.features.statistics; + +public class ExceptionResponse { + private String message; + + public ExceptionResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java new file mode 100644 index 0000000..77da428 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java @@ -0,0 +1,16 @@ +package lv.ctco.springboottemplate.features.statistics; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + ExceptionResponse error = new ExceptionResponse(ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java new file mode 100644 index 0000000..69d0d1d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java @@ -0,0 +1,16 @@ +package lv.ctco.springboottemplate.features.statistics; + +public enum ResponseFormat { + SUMMARY("summary"), + DETAILED("detailed"); + + private final String value; + + ResponseFormat(String format) { + this.value = format; + } + + public String getValue() { + return value; + } +} 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..eaea6e8 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,33 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Todo Controller", description = "Todo management endpoints") +public class StatisticsController { + + public StatisticsController() { + } + + @GetMapping + @Operation(summary = "Get todo statistics") + // @Todo - implement correct return dto + public List getStatistics( + @Nullable @RequestParam(name = "from", required = false) LocalDate from, + @Nullable @RequestParam(name = "to", required = false) LocalDate to, + @RequestParam ResponseFormat format) { + + if (from == null && to == null) { + throw new RuntimeException("Either 'from' or 'to' should be provided"); + } + + return List.of(); + } +} diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java new file mode 100644 index 0000000..03e2623 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -0,0 +1,43 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.config.ResponseFormatConverter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(ResponseFormatConverter.class) +class StatisticsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getStatisticsFailsIncorrectFormat() throws Exception { + mockMvc.perform(get("/api/statistics") + .param("format", "unknown") + ) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Method parameter 'format': Failed to convert"))); + } + + @Test + void getStatisticsFailsMissingFromAndTo() throws Exception { + mockMvc.perform(get("/api/statistics") + .param("format", "summary") + ) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Either 'from' or 'to' should be provided"))); + } +} \ No newline at end of file From b96aca98439a5c9712d1e5897485ebbf6dce3bf5 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Thu, 23 Oct 2025 17:22:11 +0300 Subject: [PATCH 02/16] Task 2 - todo summary stats --- .../statistics/StatisticsController.java | 11 +++--- .../statistics/StatisticsService.java | 16 ++++++++ .../statistics/TodoStatsRepository.java | 39 +++++++++++++++++++ .../statistics/TodoSummaryStatsDto.java | 10 +++++ .../features/todo/TodoDataInitializer.java | 21 +++++++++- 5 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.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 index eaea6e8..57ee874 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -6,20 +6,21 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.util.List; @RestController @RequestMapping("/api/statistics") @Tag(name = "Todo Controller", description = "Todo management endpoints") public class StatisticsController { - public StatisticsController() { + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; } @GetMapping @Operation(summary = "Get todo statistics") - // @Todo - implement correct return dto - public List getStatistics( + public TodoSummaryStatsDto getStatistics( @Nullable @RequestParam(name = "from", required = false) LocalDate from, @Nullable @RequestParam(name = "to", required = false) LocalDate to, @RequestParam ResponseFormat format) { @@ -28,6 +29,6 @@ public List getStatistics( throw new RuntimeException("Either 'from' or 'to' should be provided"); } - return List.of(); + return statisticsService.getStatistics(); } } 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..489c337 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,16 @@ +package lv.ctco.springboottemplate.features.statistics; + +import org.springframework.stereotype.Service; + +@Service +public class StatisticsService { + private final TodoStatsRepository todoStatsRepository; + + public StatisticsService(TodoStatsRepository todoRepository) { + this.todoStatsRepository = todoRepository; + } + + public TodoSummaryStatsDto getStatistics() { + return todoStatsRepository.getStats(); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java new file mode 100644 index 0000000..1a6d1f8 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -0,0 +1,39 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.features.todo.Todo; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.bson.Document; + +import java.util.HashMap; +import java.util.Map; +@Repository +public class TodoStatsRepository { + + @Autowired + private MongoTemplate mongoTemplate; + + public TodoSummaryStatsDto getStats() { + long total = mongoTemplate.count(new Query(), Todo.class); + long completed = mongoTemplate.count(Query.query(Criteria.where("completed").is(true)), Todo.class); + long pending = total - completed; + + Aggregation agg = Aggregation.newAggregation( + Aggregation.group("createdBy").count().as("count") + ); + + AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); + Map userStats = new HashMap<>(); + for (Document doc : results.getMappedResults()) { + userStats.put(doc.getString("_id"), doc.getInteger("count").longValue()); + } + + return new TodoSummaryStatsDto(total, completed, pending, userStats); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java new file mode 100644 index 0000000..5d0d1f2 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.util.Map; + +public record TodoSummaryStatsDto( + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats +){} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java index 2b24a9a..3248b67 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java @@ -58,7 +58,26 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { "system", "system", now, - now)); + now), + new Todo( + null, + "Plan Task 2", + "Research mongo aggregates", + true, + "warlock", + "warlock", + now, + now), + new Todo( + null, + "Task 2 detailed dto", + "Research mongo aggregates", + false, + "warlock", + "warlock", + now, + now) + ); todoRepository.saveAll(todos); log.info("Initialized database with {} todo items", todos.size()); From ff24726b53458490c64d00774ca5673df3c5d01d Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Fri, 24 Oct 2025 15:22:06 +0300 Subject: [PATCH 03/16] Task 2 - experiment detailed view --- .../features/statistics/CompletedTodo.java | 11 ++++ .../features/statistics/PendingTodo.java | 10 ++++ .../statistics/StatisticsController.java | 14 +++++ .../statistics/StatisticsService.java | 4 ++ .../statistics/TodoDetailedStatsDto.java | 14 +++++ .../features/statistics/TodoDetails.java | 8 +++ .../statistics/TodoStatsRepository.java | 52 +++++++++++++++++++ 7 files changed, 113 insertions(+) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java new file mode 100644 index 0000000..c0e4ee6 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java @@ -0,0 +1,11 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.Instant; + +public record CompletedTodo( + String id, + String title, + String createdBy, + Instant createdAt, + Instant completedAt +) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java new file mode 100644 index 0000000..4353024 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.Instant; + +public record PendingTodo( + String id, + String title, + String createdBy, + Instant createdAt +) {} 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 57ee874..8d17318 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -31,4 +31,18 @@ public TodoSummaryStatsDto getStatistics( return statisticsService.getStatistics(); } + + @GetMapping("/expanded") + @Operation(summary = "Get todo statistics") + public TodoSummaryStatsDto getExpandedStatistics( + @Nullable @RequestParam(name = "from", required = false) LocalDate from, + @Nullable @RequestParam(name = "to", required = false) LocalDate to, + @RequestParam ResponseFormat format) { + + if (from == null && to == null) { + throw new RuntimeException("Either 'from' or 'to' should be provided"); + } + + return statisticsService.getExpandedStatistics(); + } } 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 489c337..652e432 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -13,4 +13,8 @@ public StatisticsService(TodoStatsRepository todoRepository) { public TodoSummaryStatsDto getStatistics() { return todoStatsRepository.getStats(); } + + public TodoDetailedStatsDto getExpandedStatistics() { + return todoStatsRepository.getExpandedStats(); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java new file mode 100644 index 0000000..8eacc84 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java @@ -0,0 +1,14 @@ +package lv.ctco.springboottemplate.features.statistics; + +import org.springframework.lang.Nullable; + +import java.util.Map; + +public record TodoDetailedStatsDto( + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats, + TodoDetails todos +) {} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java new file mode 100644 index 0000000..424dfdb --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java @@ -0,0 +1,8 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.util.List; + +public record TodoDetails( + List completed, + List pending +) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 1a6d1f8..263f481 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -12,6 +12,7 @@ import org.bson.Document; import java.util.HashMap; +import java.util.List; import java.util.Map; @Repository public class TodoStatsRepository { @@ -36,4 +37,55 @@ public TodoSummaryStatsDto getStats() { return new TodoSummaryStatsDto(total, completed, pending, userStats); } + + public TodoDetailedStatsDto getExpandedStats() { + long total = mongoTemplate.count(new Query(), Todo.class); + long completed = mongoTemplate.count(Query.query(Criteria.where("completed").is(true)), Todo.class); + long pending = total - completed; + + // Aggregate user stats + Aggregation agg = Aggregation.newAggregation( + Aggregation.group("createdBy").count().as("count") + ); + AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); + Map userStats = new HashMap<>(); + for (Document doc : results.getMappedResults()) { + userStats.put(doc.getString("_id"), ((Number) doc.get("count")).longValue()); + } + + // Fetch completed todos + List completedTodos = mongoTemplate.find( + Query.query(Criteria.where("completed").is(true)), + Todo.class + ).stream() + .map(todo -> new CompletedTodo( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt(), + todo.updatedAt() // assuming updatedAt = completedAt + )) + .toList(); + + // Fetch pending todos + List pendingTodos = mongoTemplate.find( + Query.query(Criteria.where("completed").is(false)), + Todo.class + ).stream() + .map(todo -> new PendingTodo( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt() + )) + .toList(); + + return new TodoDetailedStatsDto( + total, + completed, + pending, + userStats, + new TodoDetails(completedTodos, pendingTodos) + ); + } } From 0467a7708755e0a84b1a20de9844c75098992326 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Fri, 24 Oct 2025 15:23:48 +0300 Subject: [PATCH 04/16] Task 2 - fix training controller path return type --- .../features/statistics/StatisticsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8d17318..dc195a8 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -34,7 +34,7 @@ public TodoSummaryStatsDto getStatistics( @GetMapping("/expanded") @Operation(summary = "Get todo statistics") - public TodoSummaryStatsDto getExpandedStatistics( + public TodoDetailedStatsDto getExpandedStatistics( @Nullable @RequestParam(name = "from", required = false) LocalDate from, @Nullable @RequestParam(name = "to", required = false) LocalDate to, @RequestParam ResponseFormat format) { From 876d92412124088109e31342a8bddad84165d459 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Fri, 24 Oct 2025 16:50:32 +0300 Subject: [PATCH 05/16] Task 2 - reuse general summary for detailed --- .../statistics/TodoStatsRepository.java | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 263f481..8e6cdb2 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -39,19 +39,7 @@ public TodoSummaryStatsDto getStats() { } public TodoDetailedStatsDto getExpandedStats() { - long total = mongoTemplate.count(new Query(), Todo.class); - long completed = mongoTemplate.count(Query.query(Criteria.where("completed").is(true)), Todo.class); - long pending = total - completed; - - // Aggregate user stats - Aggregation agg = Aggregation.newAggregation( - Aggregation.group("createdBy").count().as("count") - ); - AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); - Map userStats = new HashMap<>(); - for (Document doc : results.getMappedResults()) { - userStats.put(doc.getString("_id"), ((Number) doc.get("count")).longValue()); - } + TodoSummaryStatsDto summaryStats = getStats(); // Fetch completed todos List completedTodos = mongoTemplate.find( @@ -81,10 +69,10 @@ public TodoDetailedStatsDto getExpandedStats() { .toList(); return new TodoDetailedStatsDto( - total, - completed, - pending, - userStats, + summaryStats.totalTodos(), + summaryStats.completedTodos(), + summaryStats.pendingTodos(), + summaryStats.userStats(), new TodoDetails(completedTodos, pendingTodos) ); } From 14c5f0c3b4a0b66b45eda8f9f5aa178b7678be16 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Sat, 25 Oct 2025 11:57:34 +0300 Subject: [PATCH 06/16] Task 2 - query only needed fields --- .../statistics/TodoStatsRepository.java | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 8e6cdb2..8958a11 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -41,32 +41,24 @@ public TodoSummaryStatsDto getStats() { public TodoDetailedStatsDto getExpandedStats() { TodoSummaryStatsDto summaryStats = getStats(); - // Fetch completed todos - List completedTodos = mongoTemplate.find( - Query.query(Criteria.where("completed").is(true)), - Todo.class - ).stream() - .map(todo -> new CompletedTodo( - todo.id(), - todo.title(), - todo.createdBy(), - todo.createdAt(), - todo.updatedAt() // assuming updatedAt = completedAt - )) - .toList(); + Query queryCompleted = new Query(Criteria.where("completed").is(true)); + queryCompleted.fields() + .include("id") + .include("title") + .include("createdBy") + .include("createdAt") + .include("completedAt"); - // Fetch pending todos - List pendingTodos = mongoTemplate.find( - Query.query(Criteria.where("completed").is(false)), - Todo.class - ).stream() - .map(todo -> new PendingTodo( - todo.id(), - todo.title(), - todo.createdBy(), - todo.createdAt() - )) - .toList(); + List completedTodos = mongoTemplate.find(queryCompleted, CompletedTodo.class, "todos"); + + Query queryPending = new Query(Criteria.where("completed").is(false)); + queryPending.fields() + .include("id") + .include("title") + .include("createdBy") + .include("createdAt"); + + List pendingTodos = mongoTemplate.find(queryPending, PendingTodo.class, "todos"); return new TodoDetailedStatsDto( summaryStats.totalTodos(), From 038750a27e4bc9843aa0a27c33bc1de32349f4b8 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Sat, 25 Oct 2025 12:22:39 +0300 Subject: [PATCH 07/16] Task 2 - rename dtos, move to separate package. Combine summary and detailed requests --- .../statistics/StatisticsController.java | 22 +++++-------------- .../statistics/StatisticsService.java | 2 ++ .../features/statistics/TodoDetails.java | 8 ------- .../statistics/TodoStatsRepository.java | 7 +++--- .../CompletedTodoDto.java} | 4 ++-- .../PendingTodoDto.java} | 4 ++-- .../{ => dto}/TodoDetailedStatsDto.java | 8 +++---- .../statistics/dto/TodoDetailsDto.java | 8 +++++++ .../features/statistics/dto/TodoStatsDto.java | 3 +++ .../{ => dto}/TodoSummaryStatsDto.java | 4 ++-- 10 files changed, 32 insertions(+), 38 deletions(-) delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java rename src/main/java/lv/ctco/springboottemplate/features/statistics/{CompletedTodo.java => dto/CompletedTodoDto.java} (62%) rename src/main/java/lv/ctco/springboottemplate/features/statistics/{PendingTodo.java => dto/PendingTodoDto.java} (58%) rename src/main/java/lv/ctco/springboottemplate/features/statistics/{ => dto}/TodoDetailedStatsDto.java (57%) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java rename src/main/java/lv/ctco/springboottemplate/features/statistics/{ => dto}/TodoSummaryStatsDto.java (64%) 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 dc195a8..ef10a43 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.*; @@ -11,7 +12,6 @@ @RequestMapping("/api/statistics") @Tag(name = "Todo Controller", description = "Todo management endpoints") public class StatisticsController { - private final StatisticsService statisticsService; public StatisticsController(StatisticsService statisticsService) { @@ -20,7 +20,7 @@ public StatisticsController(StatisticsService statisticsService) { @GetMapping @Operation(summary = "Get todo statistics") - public TodoSummaryStatsDto getStatistics( + public TodoStatsDto getStatistics( @Nullable @RequestParam(name = "from", required = false) LocalDate from, @Nullable @RequestParam(name = "to", required = false) LocalDate to, @RequestParam ResponseFormat format) { @@ -29,20 +29,10 @@ public TodoSummaryStatsDto getStatistics( throw new RuntimeException("Either 'from' or 'to' should be provided"); } - return statisticsService.getStatistics(); - } - - @GetMapping("/expanded") - @Operation(summary = "Get todo statistics") - public TodoDetailedStatsDto getExpandedStatistics( - @Nullable @RequestParam(name = "from", required = false) LocalDate from, - @Nullable @RequestParam(name = "to", required = false) LocalDate to, - @RequestParam ResponseFormat format) { - - if (from == null && to == null) { - throw new RuntimeException("Either 'from' or 'to' should be provided"); + if (format == ResponseFormat.SUMMARY) { + return statisticsService.getStatistics(); + } else { + return statisticsService.getExpandedStatistics(); } - - return statisticsService.getExpandedStatistics(); } } 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 652e432..0ca9641 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -1,5 +1,7 @@ package lv.ctco.springboottemplate.features.statistics; +import lv.ctco.springboottemplate.features.statistics.dto.TodoDetailedStatsDto; +import lv.ctco.springboottemplate.features.statistics.dto.TodoSummaryStatsDto; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java deleted file mode 100644 index 424dfdb..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetails.java +++ /dev/null @@ -1,8 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics; - -import java.util.List; - -public record TodoDetails( - List completed, - List pending -) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 8958a11..7d08f3d 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -1,5 +1,6 @@ package lv.ctco.springboottemplate.features.statistics; +import lv.ctco.springboottemplate.features.statistics.dto.*; import lv.ctco.springboottemplate.features.todo.Todo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; @@ -49,7 +50,7 @@ public TodoDetailedStatsDto getExpandedStats() { .include("createdAt") .include("completedAt"); - List completedTodos = mongoTemplate.find(queryCompleted, CompletedTodo.class, "todos"); + List completedTodos = mongoTemplate.find(queryCompleted, CompletedTodoDto.class, "todos"); Query queryPending = new Query(Criteria.where("completed").is(false)); queryPending.fields() @@ -58,14 +59,14 @@ public TodoDetailedStatsDto getExpandedStats() { .include("createdBy") .include("createdAt"); - List pendingTodos = mongoTemplate.find(queryPending, PendingTodo.class, "todos"); + List pendingTodos = mongoTemplate.find(queryPending, PendingTodoDto.class, "todos"); return new TodoDetailedStatsDto( summaryStats.totalTodos(), summaryStats.completedTodos(), summaryStats.pendingTodos(), summaryStats.userStats(), - new TodoDetails(completedTodos, pendingTodos) + new TodoDetailsDto(completedTodos, pendingTodos) ); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java similarity index 62% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java index c0e4ee6..ce5857c 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/CompletedTodo.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java @@ -1,8 +1,8 @@ -package lv.ctco.springboottemplate.features.statistics; +package lv.ctco.springboottemplate.features.statistics.dto; import java.time.Instant; -public record CompletedTodo( +public record CompletedTodoDto( String id, String title, String createdBy, diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java similarity index 58% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java index 4353024..c60fedd 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/PendingTodo.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java @@ -1,8 +1,8 @@ -package lv.ctco.springboottemplate.features.statistics; +package lv.ctco.springboottemplate.features.statistics.dto; import java.time.Instant; -public record PendingTodo( +public record PendingTodoDto( String id, String title, String createdBy, diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java similarity index 57% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java index 8eacc84..0b250f5 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoDetailedStatsDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java @@ -1,6 +1,4 @@ -package lv.ctco.springboottemplate.features.statistics; - -import org.springframework.lang.Nullable; +package lv.ctco.springboottemplate.features.statistics.dto; import java.util.Map; @@ -9,6 +7,6 @@ public record TodoDetailedStatsDto( long completedTodos, long pendingTodos, Map userStats, - TodoDetails todos -) {} + TodoDetailsDto todos +) implements TodoStatsDto {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java new file mode 100644 index 0000000..9dfdf4e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java @@ -0,0 +1,8 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import java.util.List; + +public record TodoDetailsDto( + List completed, + List pending +) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java new file mode 100644 index 0000000..ea7029e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java @@ -0,0 +1,3 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +public sealed interface TodoStatsDto permits TodoSummaryStatsDto, TodoDetailedStatsDto {} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java similarity index 64% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java index 5d0d1f2..3e9eaa3 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoSummaryStatsDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java @@ -1,4 +1,4 @@ -package lv.ctco.springboottemplate.features.statistics; +package lv.ctco.springboottemplate.features.statistics.dto; import java.util.Map; @@ -7,4 +7,4 @@ public record TodoSummaryStatsDto( long completedTodos, long pendingTodos, Map userStats -){} +) implements TodoStatsDto {} From 4122fdcb2f4fa421538eababe68686c0ba5bb43d Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Mon, 27 Oct 2025 12:35:00 +0200 Subject: [PATCH 08/16] Task 2, read completed date from updated at --- .../statistics/TodoStatsRepository.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 7d08f3d..ef11723 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -15,6 +15,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; + @Repository public class TodoStatsRepository { @@ -42,15 +43,19 @@ public TodoSummaryStatsDto getStats() { public TodoDetailedStatsDto getExpandedStats() { TodoSummaryStatsDto summaryStats = getStats(); - Query queryCompleted = new Query(Criteria.where("completed").is(true)); - queryCompleted.fields() - .include("id") - .include("title") - .include("createdBy") - .include("createdAt") - .include("completedAt"); + Aggregation aggregateCompleted = Aggregation.newAggregation( + Aggregation.match(Criteria.where("completed").is(true)), + Aggregation.project() + .and("_id").as("id") + .and("title").as("title") + .and("createdBy").as("createdBy") + .and("createdAt").as("createdAt") + .and("updatedAt").as("completedAt") + ); - List completedTodos = mongoTemplate.find(queryCompleted, CompletedTodoDto.class, "todos"); + List completedTodos = mongoTemplate + .aggregate(aggregateCompleted, "todos", CompletedTodoDto.class) + .getMappedResults(); Query queryPending = new Query(Criteria.where("completed").is(false)); queryPending.fields() From 9bfb5797840e1ea825803100a60ef4b82eab6cf6 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Mon, 27 Oct 2025 14:31:22 +0200 Subject: [PATCH 09/16] Task 2, experiment larger aggregate --- .../statistics/TodoStatsRepository.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index ef11723..78ae9f9 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -43,6 +43,8 @@ public TodoSummaryStatsDto getStats() { public TodoDetailedStatsDto getExpandedStats() { TodoSummaryStatsDto summaryStats = getStats(); + aggregateTets(); + Aggregation aggregateCompleted = Aggregation.newAggregation( Aggregation.match(Criteria.where("completed").is(true)), Aggregation.project() @@ -74,4 +76,43 @@ public TodoDetailedStatsDto getExpandedStats() { new TodoDetailsDto(completedTodos, pendingTodos) ); } + + public record TodoDetailsAggregateDocuments(List completed, List pending) { + } + + TodoDetailsDto aggregateTets() { + + Aggregation agg = Aggregation.newAggregation( + Aggregation.facet( + Aggregation.match(Criteria.where("completed").is(true)), + Aggregation.project() + .and("_id").as("id") + .and("title").as("title") + .and("createdBy").as("createdBy") + .and("createdAt").as("createdAt") + .and("updatedAt").as("completedAt") + ).as("completed") + .and( + Aggregation.match(Criteria.where("completed").is(false)), + Aggregation.project() + .and("_id").as("id") + .and("title").as("title") + .and("createdBy").as("createdBy") + .and("createdAt").as("createdAt") + ).as("pending") + ); + + AggregationResults result = mongoTemplate.aggregate(agg, "todos", TodoDetailsAggregateDocuments.class); + TodoDetailsAggregateDocuments raw = result.getUniqueMappedResult(); + + List completedTodos = raw.completed().stream() + .map(doc -> mongoTemplate.getConverter().read(CompletedTodoDto.class, doc)) + .toList(); + + List pendingTodos = raw.pending().stream() + .map(doc -> mongoTemplate.getConverter().read(PendingTodoDto.class, doc)) + .toList(); + + return new TodoDetailsDto(completedTodos, pendingTodos); + } } From 312204d538de31a2fb307514844f6618ea6c5f5c Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Mon, 27 Oct 2025 17:39:08 +0200 Subject: [PATCH 10/16] Task 2, everything in aggregate. Used Chat gpt :) --- .../statistics/StatisticsController.java | 12 +- .../statistics/StatisticsService.java | 13 +- .../statistics/TodoStatsRepository.java | 204 +++++++++++------- .../statistics/dto/TodoDetailedStatsDto.java | 12 -- .../features/statistics/dto/TodoStatsDto.java | 13 +- .../statistics/dto/TodoSummaryStatsDto.java | 10 - 6 files changed, 145 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.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 index ef10a43..6bb1d2f 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -6,7 +6,9 @@ import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneOffset; @RestController @RequestMapping("/api/statistics") @@ -29,10 +31,10 @@ public TodoStatsDto getStatistics( throw new RuntimeException("Either 'from' or 'to' should be provided"); } - if (format == ResponseFormat.SUMMARY) { - return statisticsService.getStatistics(); - } else { - return statisticsService.getExpandedStatistics(); - } + Instant fromInstant = from == null ? null : from.atStartOfDay(ZoneOffset.UTC).toInstant(); + Instant toInstant = to == null ? null : to.atStartOfDay(ZoneOffset.UTC).toInstant(); + + return statisticsService.getStatistics(format, fromInstant, toInstant); + } } 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 0ca9641..e466281 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -1,9 +1,10 @@ package lv.ctco.springboottemplate.features.statistics; -import lv.ctco.springboottemplate.features.statistics.dto.TodoDetailedStatsDto; -import lv.ctco.springboottemplate.features.statistics.dto.TodoSummaryStatsDto; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.springframework.stereotype.Service; +import java.time.Instant; + @Service public class StatisticsService { private final TodoStatsRepository todoStatsRepository; @@ -12,11 +13,7 @@ public StatisticsService(TodoStatsRepository todoRepository) { this.todoStatsRepository = todoRepository; } - public TodoSummaryStatsDto getStatistics() { - return todoStatsRepository.getStats(); + public TodoStatsDto getStatistics(ResponseFormat format, Instant from, Instant to) { + return todoStatsRepository.getStats(format, from, to); } - - public TodoDetailedStatsDto getExpandedStatistics() { - return todoStatsRepository.getExpandedStats(); - } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 78ae9f9..7a80bcf 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -1,18 +1,15 @@ package lv.ctco.springboottemplate.features.statistics; import lv.ctco.springboottemplate.features.statistics.dto.*; -import lv.ctco.springboottemplate.features.todo.Todo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.*; import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; -import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.bson.Document; -import java.util.HashMap; +import java.time.Instant; import java.util.List; import java.util.Map; @@ -22,97 +19,138 @@ public class TodoStatsRepository { @Autowired private MongoTemplate mongoTemplate; - public TodoSummaryStatsDto getStats() { - long total = mongoTemplate.count(new Query(), Todo.class); - long completed = mongoTemplate.count(Query.query(Criteria.where("completed").is(true)), Todo.class); - long pending = total - completed; + Aggregation getSummaryStatsAggregation(ResponseFormat format, Instant from, Instant to) { + Criteria criteria = new Criteria(); - Aggregation agg = Aggregation.newAggregation( - Aggregation.group("createdBy").count().as("count") - ); - - AggregationResults results = mongoTemplate.aggregate(agg, "todos", Document.class); - Map userStats = new HashMap<>(); - for (Document doc : results.getMappedResults()) { - userStats.put(doc.getString("_id"), doc.getInteger("count").longValue()); + if (from != null && to != null) { + criteria = Criteria.where("createdAt").gte(from).lte(to); + criteria.lte(to); + } else if (from != null) { + criteria = Criteria.where("createdAt").gte(from); + } else if (to != null) { + criteria = Criteria.where("createdAt").lte(to); } - return new TodoSummaryStatsDto(total, completed, pending, userStats); - } - - public TodoDetailedStatsDto getExpandedStats() { - TodoSummaryStatsDto summaryStats = getStats(); + MatchOperation matchStage = Aggregation.match(criteria); + + // --- First facet: counts --- + FacetOperation countsFacet = Aggregation.facet( + Aggregation.group() + .count().as("totalTodos") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)) + .as("completedTodos") + .sum(ConditionalOperators.when(Criteria.where("completed").is(false)).then(1).otherwise(0)) + .as("pendingTodos") + ).as("counts") + + // --- Second facet: userStats --- + .and( + Aggregation.group("createdBy") + .count().as("count") + ).as("userStats"); + + + if (format == ResponseFormat.DETAILED) { + // --- Third facet: completed todos + countsFacet = countsFacet.and( + Aggregation.match(Criteria.where("completed").is(true)), + Aggregation.project("id", "title", "createdBy", "createdAt", "updatedAt") + .and("updatedAt").as("completedAt") + ).as("completedTodos") + + // --- Facet 4: pending todos --- + .and( + Aggregation.match(Criteria.where("completed").is(false)), + Aggregation.project("id", "title", "createdBy", "createdAt") + ).as("pendingTodos"); + } - aggregateTets(); + // --- Final project stage --- + ProjectionOperation project = Aggregation.project() + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.totalTodos").elementAt(0)) + .as("totalTodos") + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.completedTodos").elementAt(0)) + .as("completedTodos") + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.pendingTodos").elementAt(0)) + .as("pendingTodos") + .and("userStats").as("userStats"); + + if (format == ResponseFormat.DETAILED) { + project = project.and( + ConditionalOperators.ifNull("completedTodos").then(List.of()) + ).as("todos.completed") + .and( + ConditionalOperators.ifNull("pendingTodos").then(List.of()) + ).as("todos.pending"); + } - Aggregation aggregateCompleted = Aggregation.newAggregation( - Aggregation.match(Criteria.where("completed").is(true)), - Aggregation.project() - .and("_id").as("id") - .and("title").as("title") - .and("createdBy").as("createdBy") - .and("createdAt").as("createdAt") - .and("updatedAt").as("completedAt") - ); + // Build the full aggregation + return Aggregation.newAggregation(matchStage, countsFacet, project); - List completedTodos = mongoTemplate - .aggregate(aggregateCompleted, "todos", CompletedTodoDto.class) - .getMappedResults(); - - Query queryPending = new Query(Criteria.where("completed").is(false)); - queryPending.fields() - .include("id") - .include("title") - .include("createdBy") - .include("createdAt"); - - List pendingTodos = mongoTemplate.find(queryPending, PendingTodoDto.class, "todos"); - - return new TodoDetailedStatsDto( - summaryStats.totalTodos(), - summaryStats.completedTodos(), - summaryStats.pendingTodos(), - summaryStats.userStats(), - new TodoDetailsDto(completedTodos, pendingTodos) - ); } - public record TodoDetailsAggregateDocuments(List completed, List pending) { + Long extractLong(Document doc, String key) { + Number n = doc.get(key, Number.class); + if (n == null) { + return 0L; + } + return n.longValue(); } - TodoDetailsDto aggregateTets() { - - Aggregation agg = Aggregation.newAggregation( - Aggregation.facet( - Aggregation.match(Criteria.where("completed").is(true)), - Aggregation.project() - .and("_id").as("id") - .and("title").as("title") - .and("createdBy").as("createdBy") - .and("createdAt").as("createdAt") - .and("updatedAt").as("completedAt") - ).as("completed") - .and( - Aggregation.match(Criteria.where("completed").is(false)), - Aggregation.project() - .and("_id").as("id") - .and("title").as("title") - .and("createdBy").as("createdBy") - .and("createdAt").as("createdAt") - ).as("pending") - ); + public TodoStatsDto getStats(ResponseFormat format, Instant from, Instant to) { + Aggregation aggregation = getSummaryStatsAggregation(format, from, to); - AggregationResults result = mongoTemplate.aggregate(agg, "todos", TodoDetailsAggregateDocuments.class); - TodoDetailsAggregateDocuments raw = result.getUniqueMappedResult(); + Document doc = mongoTemplate.aggregate(aggregation, "todos", Document.class) + .getUniqueMappedResult(); - List completedTodos = raw.completed().stream() - .map(doc -> mongoTemplate.getConverter().read(CompletedTodoDto.class, doc)) - .toList(); + if (doc == null) { + return new TodoStatsDto(0, 0, 0, Map.of(), null); + } - List pendingTodos = raw.pending().stream() - .map(doc -> mongoTemplate.getConverter().read(PendingTodoDto.class, doc)) - .toList(); + List userStatsList = (List) doc.get("userStats"); + Map userStats = userStatsList.stream() + .collect(java.util.stream.Collectors.toMap( + d -> d.getString("_id"), + d -> ((Number) d.get("count")).longValue() + )); + + TodoDetailsDto details = null; + + if (format == ResponseFormat.DETAILED) { + List completedTodos = ((List) + ((Document) doc.get("todos")) + .get("completed")) + .stream() + .map(d -> new CompletedTodoDto( + d.getString("id"), + d.getString("title"), + d.getString("createdBy"), + d.getDate("createdAt").toInstant(), + d.getDate("completedAt").toInstant() + )) + .toList(); + + List pendingTodos = ((List) + ((Document) doc.get("todos")) + .get("pending")) + .stream() + .map(d -> new PendingTodoDto( + d.getString("id"), + d.getString("title"), + d.getString("createdBy"), + d.getDate("createdAt").toInstant() + )) + .toList(); + + details = new TodoDetailsDto(completedTodos, pendingTodos); + } - return new TodoDetailsDto(completedTodos, pendingTodos); + return new TodoStatsDto( + extractLong(doc, "totalTodos"), + extractLong(doc, "completedTodos"), + extractLong(doc, "pendingTodos"), + userStats, + details + ); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java deleted file mode 100644 index 0b250f5..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailedStatsDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics.dto; - -import java.util.Map; - -public record TodoDetailedStatsDto( - long totalTodos, - long completedTodos, - long pendingTodos, - Map userStats, - TodoDetailsDto todos -) implements TodoStatsDto {} - diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java index ea7029e..a4d5060 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java @@ -1,3 +1,14 @@ package lv.ctco.springboottemplate.features.statistics.dto; -public sealed interface TodoStatsDto permits TodoSummaryStatsDto, TodoDetailedStatsDto {} \ No newline at end of file +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Map; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TodoStatsDto( + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats, + TodoDetailsDto todos +) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java deleted file mode 100644 index 3e9eaa3..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoSummaryStatsDto.java +++ /dev/null @@ -1,10 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics.dto; - -import java.util.Map; - -public record TodoSummaryStatsDto( - long totalTodos, - long completedTodos, - long pendingTodos, - Map userStats -) implements TodoStatsDto {} From 66d034a68b6824ec3ac708aeb7edd1e4e5e3aef6 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Tue, 28 Oct 2025 14:37:07 +0200 Subject: [PATCH 11/16] Task 2, added tests --- .../statistics/StatisticsControllerTest.java | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java index 03e2623..53094f1 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -1,28 +1,45 @@ package lv.ctco.springboottemplate.features.statistics; +import com.fasterxml.jackson.databind.ObjectMapper; import lv.ctco.springboottemplate.config.ResponseFormatConverter; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.junit.jupiter.Testcontainers; -import java.time.LocalDate; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Map; import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc +@Testcontainers @Import(ResponseFormatConverter.class) class StatisticsControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()); + @Test void getStatisticsFailsIncorrectFormat() throws Exception { mockMvc.perform(get("/api/statistics") @@ -32,12 +49,57 @@ void getStatisticsFailsIncorrectFormat() throws Exception { .andExpect(content().string(containsString("Method parameter 'format': Failed to convert"))); } - @Test - void getStatisticsFailsMissingFromAndTo() throws Exception { + @ParameterizedTest + @ValueSource(strings = {"summary", "detailed"}) + void getStatisticsFailsMissingFromAndToWithCorrectFormat(String format) throws Exception { mockMvc.perform(get("/api/statistics") - .param("format", "summary") + .param("format", format) ) .andExpect(status().isBadRequest()) .andExpect(content().string(containsString("Either 'from' or 'to' should be provided"))); } + + @Test + void getSummaryStatisticsSucceeds() throws Exception { + var now = Instant.now().minus(1L, ChronoUnit.DAYS); + + MvcResult result = mockMvc.perform(get("/api/statistics") + .param("format", "summary") + .param("from", formatter.format(now)) + ) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); + + TodoStatsDto expected = new TodoStatsDto( + 7, 3, 4, + Map.of( + "system", 5L, + "warlock", 2L + ), + null + ); + + assertEquals(expected, statistics); + } + + @Test + void getDetailedStatisticsSucceeds() throws Exception { + var now = Instant.now().minus(1L, ChronoUnit.DAYS); + + MvcResult result = mockMvc.perform(get("/api/statistics") + .param("format", "detailed") + .param("from", formatter.format(now)) + ) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); + + assertEquals(3, statistics.todos().completed().size()); + assertEquals(4, statistics.todos().pending().size()); + } } \ No newline at end of file From 1017449aec9b13d067e45afa9538e04de72f6087 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Tue, 28 Oct 2025 15:04:42 +0200 Subject: [PATCH 12/16] Task 2, added test for "to" date --- .../config/ResponseFormatConverter.java | 8 +- .../statistics/ExceptionResponse.java | 20 +- .../statistics/GlobalExceptionHandler.java | 11 +- .../features/statistics/ResponseFormat.java | 18 +- .../statistics/StatisticsController.java | 42 ++- .../statistics/StatisticsService.java | 5 +- .../statistics/TodoStatsRepository.java | 279 +++++++++--------- .../statistics/dto/CompletedTodoDto.java | 7 +- .../statistics/dto/PendingTodoDto.java | 7 +- .../statistics/dto/TodoDetailsDto.java | 5 +- .../features/statistics/dto/TodoStatsDto.java | 12 +- .../features/todo/TodoDataInitializer.java | 47 +-- .../statistics/StatisticsControllerTest.java | 191 ++++++------ 13 files changed, 337 insertions(+), 315 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java b/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java index eca22d1..80131a1 100644 --- a/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java +++ b/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java @@ -6,8 +6,8 @@ @Component public class ResponseFormatConverter implements Converter { - @Override - public ResponseFormat convert(String value) { - return ResponseFormat.valueOf(value.toUpperCase()); - } + @Override + public ResponseFormat convert(String value) { + return ResponseFormat.valueOf(value.toUpperCase()); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java index 59bed42..8d9417d 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java @@ -1,17 +1,17 @@ package lv.ctco.springboottemplate.features.statistics; public class ExceptionResponse { - private String message; + private String message; - public ExceptionResponse(String message) { - this.message = message; - } + public ExceptionResponse(String message) { + this.message = message; + } - public String getMessage() { - return message; - } + public String getMessage() { + return message; + } - public void setMessage(String message) { - this.message = message; - } + public void setMessage(String message) { + this.message = message; + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java index 77da428..6c96439 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java @@ -2,15 +2,14 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleRuntimeException(RuntimeException ex) { - ExceptionResponse error = new ExceptionResponse(ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); - } + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + ExceptionResponse error = new ExceptionResponse(ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java index 69d0d1d..9a03b4e 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java @@ -1,16 +1,16 @@ package lv.ctco.springboottemplate.features.statistics; public enum ResponseFormat { - SUMMARY("summary"), - DETAILED("detailed"); + SUMMARY("summary"), + DETAILED("detailed"); - private final String value; + private final String value; - ResponseFormat(String format) { - this.value = format; - } + ResponseFormat(String format) { + this.value = format; + } - public String getValue() { - return value; - } + public String getValue() { + return value; + } } 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 6bb1d2f..b7bfcc1 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -2,39 +2,37 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; -import org.springframework.lang.Nullable; -import org.springframework.web.bind.annotation.*; - import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; +import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/statistics") @Tag(name = "Todo Controller", description = "Todo management endpoints") public class StatisticsController { - private final StatisticsService statisticsService; - - public StatisticsController(StatisticsService statisticsService) { - this.statisticsService = statisticsService; - } + private final StatisticsService statisticsService; - @GetMapping - @Operation(summary = "Get todo statistics") - public TodoStatsDto getStatistics( - @Nullable @RequestParam(name = "from", required = false) LocalDate from, - @Nullable @RequestParam(name = "to", required = false) LocalDate to, - @RequestParam ResponseFormat format) { + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } - if (from == null && to == null) { - throw new RuntimeException("Either 'from' or 'to' should be provided"); - } + @GetMapping + @Operation(summary = "Get todo statistics") + public TodoStatsDto getStatistics( + @Nullable @RequestParam(name = "from", required = false) LocalDate from, + @Nullable @RequestParam(name = "to", required = false) LocalDate to, + @RequestParam ResponseFormat format) { - Instant fromInstant = from == null ? null : from.atStartOfDay(ZoneOffset.UTC).toInstant(); - Instant toInstant = to == null ? null : to.atStartOfDay(ZoneOffset.UTC).toInstant(); + if (from == null && to == null) { + throw new RuntimeException("Either 'from' or 'to' should be provided"); + } - return statisticsService.getStatistics(format, fromInstant, toInstant); + Instant fromInstant = from == null ? null : from.atStartOfDay(ZoneOffset.UTC).toInstant(); + Instant toInstant = to == null ? null : to.atStartOfDay(ZoneOffset.UTC).toInstant(); - } + return statisticsService.getStatistics(format, fromInstant, toInstant); + } } 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 e466281..e1ff8b3 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -1,10 +1,9 @@ package lv.ctco.springboottemplate.features.statistics; +import java.time.Instant; import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.springframework.stereotype.Service; -import java.time.Instant; - @Service public class StatisticsService { private final TodoStatsRepository todoStatsRepository; @@ -14,6 +13,6 @@ public StatisticsService(TodoStatsRepository todoRepository) { } public TodoStatsDto getStatistics(ResponseFormat format, Instant from, Instant to) { - return todoStatsRepository.getStats(format, from, to); + return todoStatsRepository.getStats(format, from, to); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index 7a80bcf..e14fca7 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -1,156 +1,161 @@ package lv.ctco.springboottemplate.features.statistics; +import java.time.Instant; +import java.util.List; +import java.util.Map; import lv.ctco.springboottemplate.features.statistics.dto.*; +import org.bson.Document; import org.springframework.beans.factory.annotation.Autowired; 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.Repository; -import org.bson.Document; - -import java.time.Instant; -import java.util.List; -import java.util.Map; - @Repository public class TodoStatsRepository { - @Autowired - private MongoTemplate mongoTemplate; - - Aggregation getSummaryStatsAggregation(ResponseFormat format, Instant from, Instant to) { - Criteria criteria = new Criteria(); - - if (from != null && to != null) { - criteria = Criteria.where("createdAt").gte(from).lte(to); - criteria.lte(to); - } else if (from != null) { - criteria = Criteria.where("createdAt").gte(from); - } else if (to != null) { - criteria = Criteria.where("createdAt").lte(to); - } - - MatchOperation matchStage = Aggregation.match(criteria); - - // --- First facet: counts --- - FacetOperation countsFacet = Aggregation.facet( - Aggregation.group() - .count().as("totalTodos") - .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)) - .as("completedTodos") - .sum(ConditionalOperators.when(Criteria.where("completed").is(false)).then(1).otherwise(0)) - .as("pendingTodos") - ).as("counts") - - // --- Second facet: userStats --- - .and( - Aggregation.group("createdBy") - .count().as("count") - ).as("userStats"); - - - if (format == ResponseFormat.DETAILED) { - // --- Third facet: completed todos - countsFacet = countsFacet.and( - Aggregation.match(Criteria.where("completed").is(true)), - Aggregation.project("id", "title", "createdBy", "createdAt", "updatedAt") - .and("updatedAt").as("completedAt") - ).as("completedTodos") - - // --- Facet 4: pending todos --- - .and( - Aggregation.match(Criteria.where("completed").is(false)), - Aggregation.project("id", "title", "createdBy", "createdAt") - ).as("pendingTodos"); - } - - // --- Final project stage --- - ProjectionOperation project = Aggregation.project() - .and(ArrayOperators.ArrayElemAt.arrayOf("counts.totalTodos").elementAt(0)) - .as("totalTodos") - .and(ArrayOperators.ArrayElemAt.arrayOf("counts.completedTodos").elementAt(0)) - .as("completedTodos") - .and(ArrayOperators.ArrayElemAt.arrayOf("counts.pendingTodos").elementAt(0)) - .as("pendingTodos") - .and("userStats").as("userStats"); - - if (format == ResponseFormat.DETAILED) { - project = project.and( - ConditionalOperators.ifNull("completedTodos").then(List.of()) - ).as("todos.completed") - .and( - ConditionalOperators.ifNull("pendingTodos").then(List.of()) - ).as("todos.pending"); - } - - // Build the full aggregation - return Aggregation.newAggregation(matchStage, countsFacet, project); + @Autowired private MongoTemplate mongoTemplate; + + Aggregation getSummaryStatsAggregation(ResponseFormat format, Instant from, Instant to) { + Criteria criteria = new Criteria(); + if (from != null && to != null) { + criteria = Criteria.where("createdAt").gte(from).lte(to); + criteria.lte(to); + } else if (from != null) { + criteria = Criteria.where("createdAt").gte(from); + } else if (to != null) { + criteria = Criteria.where("createdAt").lte(to); } - Long extractLong(Document doc, String key) { - Number n = doc.get(key, Number.class); - if (n == null) { - return 0L; - } - return n.longValue(); + MatchOperation matchStage = Aggregation.match(criteria); + + // --- First facet: counts --- + FacetOperation countsFacet = + Aggregation.facet( + Aggregation.group() + .count() + .as("totalTodos") + .sum( + ConditionalOperators.when(Criteria.where("completed").is(true)) + .then(1) + .otherwise(0)) + .as("completedTodos") + .sum( + ConditionalOperators.when(Criteria.where("completed").is(false)) + .then(1) + .otherwise(0)) + .as("pendingTodos")) + .as("counts") + + // --- Second facet: userStats --- + .and(Aggregation.group("createdBy").count().as("count")) + .as("userStats"); + + if (format == ResponseFormat.DETAILED) { + // --- Third facet: completed todos + countsFacet = + countsFacet + .and( + Aggregation.match(Criteria.where("completed").is(true)), + Aggregation.project("id", "title", "createdBy", "createdAt", "updatedAt") + .and("updatedAt") + .as("completedAt")) + .as("completedTodos") + + // --- Facet 4: pending todos --- + .and( + Aggregation.match(Criteria.where("completed").is(false)), + Aggregation.project("id", "title", "createdBy", "createdAt")) + .as("pendingTodos"); } - public TodoStatsDto getStats(ResponseFormat format, Instant from, Instant to) { - Aggregation aggregation = getSummaryStatsAggregation(format, from, to); - - Document doc = mongoTemplate.aggregate(aggregation, "todos", Document.class) - .getUniqueMappedResult(); - - if (doc == null) { - return new TodoStatsDto(0, 0, 0, Map.of(), null); - } - - List userStatsList = (List) doc.get("userStats"); - Map userStats = userStatsList.stream() - .collect(java.util.stream.Collectors.toMap( - d -> d.getString("_id"), - d -> ((Number) d.get("count")).longValue() - )); - - TodoDetailsDto details = null; - - if (format == ResponseFormat.DETAILED) { - List completedTodos = ((List) - ((Document) doc.get("todos")) - .get("completed")) - .stream() - .map(d -> new CompletedTodoDto( - d.getString("id"), - d.getString("title"), - d.getString("createdBy"), - d.getDate("createdAt").toInstant(), - d.getDate("completedAt").toInstant() - )) - .toList(); - - List pendingTodos = ((List) - ((Document) doc.get("todos")) - .get("pending")) - .stream() - .map(d -> new PendingTodoDto( - d.getString("id"), - d.getString("title"), - d.getString("createdBy"), - d.getDate("createdAt").toInstant() - )) - .toList(); - - details = new TodoDetailsDto(completedTodos, pendingTodos); - } - - return new TodoStatsDto( - extractLong(doc, "totalTodos"), - extractLong(doc, "completedTodos"), - extractLong(doc, "pendingTodos"), - userStats, - details - ); + // --- Final project stage --- + ProjectionOperation project = + Aggregation.project() + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.totalTodos").elementAt(0)) + .as("totalTodos") + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.completedTodos").elementAt(0)) + .as("completedTodos") + .and(ArrayOperators.ArrayElemAt.arrayOf("counts.pendingTodos").elementAt(0)) + .as("pendingTodos") + .and("userStats") + .as("userStats"); + + if (format == ResponseFormat.DETAILED) { + project = + project + .and(ConditionalOperators.ifNull("completedTodos").then(List.of())) + .as("todos.completed") + .and(ConditionalOperators.ifNull("pendingTodos").then(List.of())) + .as("todos.pending"); } + + // Build the full aggregation + return Aggregation.newAggregation(matchStage, countsFacet, project); + } + + Long extractLong(Document doc, String key) { + Number n = doc.get(key, Number.class); + if (n == null) { + return 0L; + } + return n.longValue(); + } + + public TodoStatsDto getStats(ResponseFormat format, Instant from, Instant to) { + Aggregation aggregation = getSummaryStatsAggregation(format, from, to); + + Document doc = + mongoTemplate.aggregate(aggregation, "todos", Document.class).getUniqueMappedResult(); + + if (doc == null) { + return new TodoStatsDto(0, 0, 0, Map.of(), null); + } + + List userStatsList = (List) doc.get("userStats"); + Map userStats = + userStatsList.stream() + .collect( + java.util.stream.Collectors.toMap( + d -> d.getString("_id"), d -> ((Number) d.get("count")).longValue())); + + TodoDetailsDto details = null; + + if (format == ResponseFormat.DETAILED) { + List completedTodos = + ((List) ((Document) doc.get("todos")).get("completed")) + .stream() + .map( + d -> + new CompletedTodoDto( + d.getString("id"), + d.getString("title"), + d.getString("createdBy"), + d.getDate("createdAt").toInstant(), + d.getDate("completedAt").toInstant())) + .toList(); + + List pendingTodos = + ((List) ((Document) doc.get("todos")).get("pending")) + .stream() + .map( + d -> + new PendingTodoDto( + d.getString("id"), + d.getString("title"), + d.getString("createdBy"), + d.getDate("createdAt").toInstant())) + .toList(); + + details = new TodoDetailsDto(completedTodos, pendingTodos); + } + + return new TodoStatsDto( + extractLong(doc, "totalTodos"), + extractLong(doc, "completedTodos"), + extractLong(doc, "pendingTodos"), + userStats, + details); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java index ce5857c..922eda1 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java @@ -3,9 +3,4 @@ import java.time.Instant; public record CompletedTodoDto( - 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/PendingTodoDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java index c60fedd..56422d8 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java @@ -2,9 +2,4 @@ import java.time.Instant; -public record PendingTodoDto( - String id, - String title, - String createdBy, - Instant createdAt -) {} +public record PendingTodoDto(String id, String title, String createdBy, Instant createdAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java index 9dfdf4e..c6a87a9 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java @@ -2,7 +2,4 @@ import java.util.List; -public record TodoDetailsDto( - List completed, - List pending -) {} +public record TodoDetailsDto(List completed, List pending) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java index a4d5060..aa3ee4a 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java @@ -1,14 +1,12 @@ package lv.ctco.springboottemplate.features.statistics.dto; import com.fasterxml.jackson.annotation.JsonInclude; - import java.util.Map; @JsonInclude(JsonInclude.Include.NON_NULL) public record TodoStatsDto( - long totalTodos, - long completedTodos, - long pendingTodos, - Map userStats, - TodoDetailsDto todos -) {} + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats, + TodoDetailsDto todos) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java index 3248b67..862ddd0 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java @@ -1,6 +1,7 @@ package lv.ctco.springboottemplate.features.todo; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,6 +20,8 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { todoRepository.deleteAll(); // Clear existing data var now = Instant.now(); + var oldInstant = Instant.now().minus(10L, ChronoUnit.DAYS); + var todos = List.of( new Todo( @@ -60,24 +63,32 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { now, now), new Todo( - null, - "Plan Task 2", - "Research mongo aggregates", - true, - "warlock", - "warlock", - now, - now), - new Todo( - null, - "Task 2 detailed dto", - "Research mongo aggregates", - false, - "warlock", - "warlock", - now, - now) - ); + null, + "Plan Task 2", + "Research mongo aggregates", + true, + "warlock", + "warlock", + now, + now), + new Todo( + null, + "Task 2 detailed dto", + "Research mongo aggregates", + false, + "warlock", + "warlock", + now, + now), + new Todo( + null, + "Task 2 - learn basics", + "Research various java techniques", + true, + "warlock", + "warlock", + oldInstant, + oldInstant)); todoRepository.saveAll(todos); log.info("Initialized database with {} todo items", todos.size()); diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java index 53094f1..a050417 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -1,6 +1,17 @@ package lv.ctco.springboottemplate.features.statistics; +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Map; import lv.ctco.springboottemplate.config.ResponseFormatConverter; import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.junit.jupiter.api.Test; @@ -14,92 +25,106 @@ import org.springframework.test.web.servlet.MvcResult; import org.testcontainers.junit.jupiter.Testcontainers; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.util.Map; - -import static org.hamcrest.CoreMatchers.containsString; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @SpringBootTest @AutoConfigureMockMvc @Testcontainers @Import(ResponseFormatConverter.class) class StatisticsControllerTest { - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()); - - @Test - void getStatisticsFailsIncorrectFormat() throws Exception { - mockMvc.perform(get("/api/statistics") - .param("format", "unknown") - ) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("Method parameter 'format': Failed to convert"))); - } - - @ParameterizedTest - @ValueSource(strings = {"summary", "detailed"}) - void getStatisticsFailsMissingFromAndToWithCorrectFormat(String format) throws Exception { - mockMvc.perform(get("/api/statistics") - .param("format", format) - ) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("Either 'from' or 'to' should be provided"))); - } - - @Test - void getSummaryStatisticsSucceeds() throws Exception { - var now = Instant.now().minus(1L, ChronoUnit.DAYS); - - MvcResult result = mockMvc.perform(get("/api/statistics") - .param("format", "summary") - .param("from", formatter.format(now)) - ) - .andExpect(status().isOk()) - .andReturn(); - - String json = result.getResponse().getContentAsString(); - TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); - - TodoStatsDto expected = new TodoStatsDto( - 7, 3, 4, - Map.of( - "system", 5L, - "warlock", 2L - ), - null - ); - - assertEquals(expected, statistics); - } - - @Test - void getDetailedStatisticsSucceeds() throws Exception { - var now = Instant.now().minus(1L, ChronoUnit.DAYS); - - MvcResult result = mockMvc.perform(get("/api/statistics") - .param("format", "detailed") - .param("from", formatter.format(now)) - ) - .andExpect(status().isOk()) - .andReturn(); - - String json = result.getResponse().getContentAsString(); - TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); - - assertEquals(3, statistics.todos().completed().size()); - assertEquals(4, statistics.todos().pending().size()); - } -} \ No newline at end of file + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + DateTimeFormatter formatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()); + + @Test + void getStatisticsFailsIncorrectFormat() throws Exception { + mockMvc + .perform(get("/api/statistics").param("format", "unknown")) + .andExpect(status().isBadRequest()) + .andExpect( + content().string(containsString("Method parameter 'format': Failed to convert"))); + } + + @ParameterizedTest + @ValueSource(strings = {"summary", "detailed"}) + void getStatisticsFailsMissingFromAndToWithCorrectFormat(String format) throws Exception { + mockMvc + .perform(get("/api/statistics").param("format", format)) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Either 'from' or 'to' should be provided"))); + } + + @Test + void getSummaryStatisticsSucceeds() throws Exception { + var now = Instant.now().minus(1L, ChronoUnit.DAYS); + + MvcResult result = + mockMvc + .perform( + get("/api/statistics") + .param("format", "summary") + .param("from", formatter.format(now))) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); + + TodoStatsDto expected = + new TodoStatsDto( + 7, + 3, + 4, + Map.of( + "system", 5L, + "warlock", 2L), + null); + + assertEquals(expected, statistics); + } + + @Test + void getDetailedStatisticsSucceeds() throws Exception { + var now = Instant.now().minus(1L, ChronoUnit.DAYS); + + MvcResult result = + mockMvc + .perform( + get("/api/statistics") + .param("format", "detailed") + .param("from", formatter.format(now))) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); + + assertEquals(3, statistics.todos().completed().size()); + assertEquals(4, statistics.todos().pending().size()); + } + + @Test + void getDetailedStatisticsToDateSucceeds() throws Exception { + // get todo stats before now + var now = Instant.now().minus(1L, ChronoUnit.DAYS); + + MvcResult result = + mockMvc + .perform( + get("/api/statistics") + .param("format", "detailed") + .param("to", formatter.format(now))) + .andExpect(status().isOk()) + .andReturn(); + + String json = result.getResponse().getContentAsString(); + TodoStatsDto statistics = objectMapper.readValue(json, TodoStatsDto.class); + + assertEquals(0, statistics.pendingTodos()); + assertEquals(1, statistics.completedTodos()); + assertEquals(1, statistics.todos().completed().size()); + assertEquals(0, statistics.todos().pending().size()); + } +} From fca44a0d8c7f3087356750611b2ce1789953b247 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Tue, 28 Oct 2025 17:07:05 +0200 Subject: [PATCH 13/16] Task 2, fixed tests --- .../config/ResponseFormatConverter.java | 13 ------------- .../features/statistics/ExceptionResponse.java | 17 ----------------- .../statistics/GlobalExceptionHandler.java | 15 --------------- .../statistics/StatisticsController.java | 18 +++++++++++++----- .../features/statistics/StatisticsService.java | 1 + .../statistics/TodoStatsRepository.java | 7 +++++-- .../statistics/{ => dto}/ResponseFormat.java | 2 +- .../statistics/StatisticsControllerTest.java | 14 ++++++++------ 8 files changed, 28 insertions(+), 59 deletions(-) delete mode 100644 src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java rename src/main/java/lv/ctco/springboottemplate/features/statistics/{ => dto}/ResponseFormat.java (78%) diff --git a/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java b/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java deleted file mode 100644 index 80131a1..0000000 --- a/src/main/java/lv/ctco/springboottemplate/config/ResponseFormatConverter.java +++ /dev/null @@ -1,13 +0,0 @@ -package lv.ctco.springboottemplate.config; - -import lv.ctco.springboottemplate.features.statistics.ResponseFormat; -import org.springframework.core.convert.converter.Converter; -import org.springframework.stereotype.Component; - -@Component -public class ResponseFormatConverter implements Converter { - @Override - public ResponseFormat convert(String value) { - return ResponseFormat.valueOf(value.toUpperCase()); - } -} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java deleted file mode 100644 index 8d9417d..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/ExceptionResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics; - -public class ExceptionResponse { - private String message; - - public ExceptionResponse(String message) { - this.message = message; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java deleted file mode 100644 index 6c96439..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/GlobalExceptionHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -package lv.ctco.springboottemplate.features.statistics; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -@RestControllerAdvice -public class GlobalExceptionHandler { - @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleRuntimeException(RuntimeException ex) { - ExceptionResponse error = new ExceptionResponse(ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); - } -} 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 b7bfcc1..ff975ea 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -5,7 +5,8 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; -import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; +import lv.ctco.springboottemplate.features.statistics.dto.ResponseFormat; +import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.*; @@ -21,18 +22,25 @@ public StatisticsController(StatisticsService statisticsService) { @GetMapping @Operation(summary = "Get todo statistics") - public TodoStatsDto getStatistics( + public ResponseEntity getStatistics( @Nullable @RequestParam(name = "from", required = false) LocalDate from, @Nullable @RequestParam(name = "to", required = false) LocalDate to, - @RequestParam ResponseFormat format) { + @RequestParam(name = "format") String formatString) { + + ResponseFormat format = ResponseFormat.SUMMARY; + try { + format = ResponseFormat.valueOf(formatString.toUpperCase()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body("Method parameter 'format': Failed to convert"); + } if (from == null && to == null) { - throw new RuntimeException("Either 'from' or 'to' should be provided"); + return ResponseEntity.badRequest().body("Either 'from' or 'to' should be provided"); } Instant fromInstant = from == null ? null : from.atStartOfDay(ZoneOffset.UTC).toInstant(); Instant toInstant = to == null ? null : to.atStartOfDay(ZoneOffset.UTC).toInstant(); - return statisticsService.getStatistics(format, fromInstant, toInstant); + return ResponseEntity.ok(statisticsService.getStatistics(format, fromInstant, toInstant)); } } 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 e1ff8b3..f1ae18b 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -1,6 +1,7 @@ package lv.ctco.springboottemplate.features.statistics; import java.time.Instant; +import lv.ctco.springboottemplate.features.statistics.dto.ResponseFormat; import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.springframework.stereotype.Service; diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java index e14fca7..50d0600 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -5,7 +5,6 @@ import java.util.Map; import lv.ctco.springboottemplate.features.statistics.dto.*; import org.bson.Document; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.*; import org.springframework.data.mongodb.core.query.Criteria; @@ -14,7 +13,11 @@ @Repository public class TodoStatsRepository { - @Autowired private MongoTemplate mongoTemplate; + private final MongoTemplate mongoTemplate; + + public TodoStatsRepository(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } Aggregation getSummaryStatsAggregation(ResponseFormat format, Instant from, Instant to) { Criteria criteria = new Criteria(); diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java similarity index 78% rename from src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java rename to src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java index 9a03b4e..d8a5f35 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/ResponseFormat.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java @@ -1,4 +1,4 @@ -package lv.ctco.springboottemplate.features.statistics; +package lv.ctco.springboottemplate.features.statistics.dto; public enum ResponseFormat { SUMMARY("summary"), diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java index a050417..883fc7a 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -12,15 +12,13 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.Map; -import lv.ctco.springboottemplate.config.ResponseFormatConverter; import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsDto; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestConstructor; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.testcontainers.junit.jupiter.Testcontainers; @@ -28,12 +26,16 @@ @SpringBootTest @AutoConfigureMockMvc @Testcontainers -@Import(ResponseFormatConverter.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class StatisticsControllerTest { + private final MockMvc mockMvc; - @Autowired private MockMvc mockMvc; + private final ObjectMapper objectMapper; - @Autowired private ObjectMapper objectMapper; + public StatisticsControllerTest(ObjectMapper objectMapper, MockMvc mockMvc) { + this.objectMapper = objectMapper; + this.mockMvc = mockMvc; + } DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()); From 3ef23a7df55758e637078c04b546b74b2e634a83 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Tue, 28 Oct 2025 18:08:55 +0200 Subject: [PATCH 14/16] try fixing test on CI - comment out annotation --- .../features/statistics/StatisticsControllerTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java index 883fc7a..1342e38 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -21,11 +21,12 @@ import org.springframework.test.context.TestConstructor; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -import org.testcontainers.junit.jupiter.Testcontainers; + +// import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @AutoConfigureMockMvc -@Testcontainers +// @Testcontainers @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class StatisticsControllerTest { private final MockMvc mockMvc; From 831c496cd9a9eca9f466e154f5d472e6059bd8fc Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Tue, 28 Oct 2025 18:48:12 +0200 Subject: [PATCH 15/16] try fixing test - mongo container added --- .../statistics/StatisticsControllerTest.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java index 1342e38..0d5aed4 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -18,17 +18,27 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestConstructor; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; - -// import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @AutoConfigureMockMvc -// @Testcontainers +@Testcontainers @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class StatisticsControllerTest { + @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 MockMvc mockMvc; private final ObjectMapper objectMapper; From 9e57c0971cbf09570f663a40b42303791164d852 Mon Sep 17 00:00:00 2001 From: Aleksandrs Semjonovs Date: Sun, 9 Nov 2025 15:39:55 +0200 Subject: [PATCH 16/16] cr suggestion - documentation --- .../statistics/StatisticsController.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) 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 ff975ea..3b0dcab 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -1,11 +1,13 @@ 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.tags.Tag; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import lv.ctco.springboottemplate.features.statistics.dto.ResponseFormat; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.web.bind.annotation.*; @@ -23,11 +25,28 @@ public StatisticsController(StatisticsService statisticsService) { @GetMapping @Operation(summary = "Get todo statistics") public ResponseEntity getStatistics( - @Nullable @RequestParam(name = "from", required = false) LocalDate from, - @Nullable @RequestParam(name = "to", required = false) LocalDate to, - @RequestParam(name = "format") String formatString) { + @Parameter(description = "From date in ISO format (yyyy-MM-dd)") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Nullable + @RequestParam(name = "from", required = false) + LocalDate from, + @Parameter(description = "To date in ISO format (yyyy-MM-dd)") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Nullable + @RequestParam(name = "to", required = false) + LocalDate to, + @Parameter( + description = + """ + Response format. **Allowed values**:
+ - `summary`: returns overview information.
+ - `detailed`: returns overview information and separate complete and pending Todos. + """, + example = "summary") + @RequestParam(name = "format") + String formatString) { - ResponseFormat format = ResponseFormat.SUMMARY; + ResponseFormat format; try { format = ResponseFormat.valueOf(formatString.toUpperCase()); } catch (IllegalArgumentException e) {