diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 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..3b0dcab --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,65 @@ +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.*; + +@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; + } + + @GetMapping + @Operation(summary = "Get todo statistics") + public ResponseEntity getStatistics( + @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; + try { + format = ResponseFormat.valueOf(formatString.toUpperCase()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body("Method parameter 'format': Failed to convert"); + } + + if (from == null && to == null) { + 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 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 new file mode 100644 index 0000000..f1ae18b --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,19 @@ +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; + +@Service +public class StatisticsService { + private final TodoStatsRepository todoStatsRepository; + + public StatisticsService(TodoStatsRepository todoRepository) { + this.todoStatsRepository = todoRepository; + } + + public TodoStatsDto getStatistics(ResponseFormat format, Instant from, Instant 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 new file mode 100644 index 0000000..50d0600 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/TodoStatsRepository.java @@ -0,0 +1,164 @@ +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.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.*; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Repository; + +@Repository +public class TodoStatsRepository { + + private final MongoTemplate mongoTemplate; + + public TodoStatsRepository(MongoTemplate mongoTemplate) { + this.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); + } + + 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 new file mode 100644 index 0000000..922eda1 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/CompletedTodoDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import java.time.Instant; + +public record CompletedTodoDto( + 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 new file mode 100644 index 0000000..56422d8 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/PendingTodoDto.java @@ -0,0 +1,5 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +import java.time.Instant; + +public record PendingTodoDto(String id, String title, String createdBy, Instant createdAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java new file mode 100644 index 0000000..d8a5f35 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/ResponseFormat.java @@ -0,0 +1,16 @@ +package lv.ctco.springboottemplate.features.statistics.dto; + +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/dto/TodoDetailsDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java new file mode 100644 index 0000000..c6a87a9 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoDetailsDto.java @@ -0,0 +1,5 @@ +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..aa3ee4a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/dto/TodoStatsDto.java @@ -0,0 +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) {} 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..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( @@ -58,7 +61,34 @@ 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), + 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 new file mode 100644 index 0000000..0d5aed4 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerTest.java @@ -0,0 +1,143 @@ +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.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.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.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@AutoConfigureMockMvc +@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; + + public StatisticsControllerTest(ObjectMapper objectMapper, MockMvc mockMvc) { + this.objectMapper = objectMapper; + this.mockMvc = mockMvc; + } + + 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()); + } +}