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