From 445101651675173f105b4a9d67a91a1b0ea54f18 Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Wed, 3 Sep 2025 21:59:06 +0300 Subject: [PATCH 1/7] feat(task 2): add todos statistics controller and service --- .../features/todo/Todo.java | 3 +- .../features/todo/TodoDataInitializer.java | 83 +++++++++++-- .../features/todo/TodoService.java | 5 +- .../TodoStatisticsController.java | 33 +++++ .../TodoStatisticsExceptionHandler.java | 41 +++++++ .../TodoStatisticsFormatEnum.java | 13 ++ .../TodoStatisticsService.java | 115 ++++++++++++++++++ .../todo_statistics/dto/TodoStatistics.java | 73 +++++++++++ .../dto/TodoStatisticsAggregationRow.java | 45 +++++++ .../dto/TodoStatisticsRequest.java | 42 +++++++ .../features/todo_statistics/dto/Todos.java | 10 ++ .../TodoStatisticsDateRangeValidator.java | 26 ++++ .../TodoStatisticsValidDateRange.java | 18 +++ .../validators/TodoStatisticsValidFormat.java | 18 +++ .../TodoStatisticsValidFormatValidator.java | 22 ++++ .../TodoStatisticsControllerTest.java | 100 +++++++++++++++ 16 files changed, 634 insertions(+), 13 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsController.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatistics.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsAggregationRow.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/Todos.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsDateRangeValidator.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/Todo.java b/src/main/java/lv/ctco/springboottemplate/features/todo/Todo.java index 27f5860..8a2a7ca 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/Todo.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/Todo.java @@ -32,4 +32,5 @@ public record Todo( String createdBy, String updatedBy, Instant createdAt, - Instant updatedAt) {} + Instant updatedAt, + Instant completedAt) {} 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..bb7a028 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java @@ -22,14 +22,23 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { var todos = List.of( new Todo( - null, "Buy groceries", "Milk, eggs, bread", false, "system", "system", now, now), + null, + "Buy groceries", + "Milk, eggs, bread", + false, + "user3", + "user3", + now, + null, + null), new Todo( null, "Call dentist", "Schedule annual checkup", true, + "user1", "system", - "system", + now, now, now), new Todo( @@ -37,28 +46,82 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { "Fix bug in production", "High priority issue #123", false, - "system", - "system", + "user1", + "user3", now, - now), + null, + null), new Todo( null, "Write documentation", "Update API docs", false, - "system", - "system", + "user2", + "user4", now, - now), + null, + null), new Todo( null, "Plan vacation", "Research destinations", true, + "user2", + "user5", + now, + now, + now), + // 👇 new 5 todos + new Todo( + null, + "Clean workspace", + "Organize desk and archive old files", + false, + "user2", + "user1", + now, + null, + null), + new Todo( + null, + "Submit expense report", + "Expenses for August", + true, + "user3", "system", - "system", now, - now)); + now, + now), + new Todo( + null, + "Prepare presentation", + "Slides for Monday’s meeting", + false, + "user3", + "user2", + now, + null, + null), + new Todo( + null, + "Refactor authentication module", + "Improve security and code quality", + true, + "user1", + "user2", + now, + now, + now), + new Todo( + null, + "Read new tech article", + "Microservices best practices", + false, + "user2", + "user3", + now, + null, + null)); todoRepository.saveAll(todos); log.info("Initialized database with {} todo items", todos.size()); diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java index 3aada7a..73f9b95 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -50,7 +50,7 @@ public List searchTodos(String title) { public Todo createTodo(String title, String description, boolean completed, String createdBy) { var now = Instant.now(); - var todo = new Todo(null, title, description, completed, createdBy, createdBy, now, now); + var todo = new Todo(null, title, description, completed, createdBy, createdBy, now, now, null); return todoRepository.save(todo); } @@ -69,7 +69,8 @@ public Optional updateTodo( existingTodo.createdBy(), updatedBy, existingTodo.createdAt(), - Instant.now()); + Instant.now(), + completed ? Instant.now() : existingTodo.completedAt()); return todoRepository.save(updatedTodo); }); } diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsController.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsController.java new file mode 100644 index 0000000..8dec27e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsController.java @@ -0,0 +1,33 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatistics; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatisticsRequest; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** REST controller for getting statistics for {@link Todo} items. */ +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Statistics Controller", description = "Provides statistics for Todo items") +public class TodoStatisticsController { + private final TodoStatisticsService statisticsService; + + public TodoStatisticsController(TodoStatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation(summary = "Get todo items statistics") + public ResponseEntity getStatistics( + @Valid @ParameterObject @ModelAttribute TodoStatisticsRequest request) { + + TodoStatistics stats = statisticsService.makeStatistics(request); + + return ResponseEntity.ok(stats); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java new file mode 100644 index 0000000..16e67f0 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java @@ -0,0 +1,41 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class TodoStatisticsExceptionHandler { + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + public ResponseEntity> handleValidationExceptions(Exception ex) { + List errors = new ArrayList<>(); + + if (ex instanceof MethodArgumentNotValidException invalidArgumentException) { + errors = + invalidArgumentException.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .toList(); + } else if (ex instanceof BindException be) { + errors = + be.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .toList(); + } + + return ResponseEntity.badRequest() + .body(Map.of("status", 400, "error", "Bad Request", "messages", errors)); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleServerError(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error", "details", ex.getMessage())); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java new file mode 100644 index 0000000..152706c --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum TodoStatisticsFormatEnum { + summary, + detailed; + + @JsonCreator + public static TodoStatisticsFormatEnum fromString(String value) { + return TodoStatisticsFormatEnum.valueOf(value.toLowerCase()); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java new file mode 100644 index 0000000..4951df3 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java @@ -0,0 +1,115 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import com.mongodb.BasicDBObject; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatistics; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatisticsAggregationRow; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatisticsRequest; +import lv.ctco.springboottemplate.features.todo_statistics.dto.Todos; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Service; + +@Service +public class TodoStatisticsService { + private final MongoTemplate mongoTemplate; + + private TodoStatisticsService(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public TodoStatistics makeStatistics(TodoStatisticsRequest request) { + setRequestParams(request); + + long total = 0; + long completed = 0; + long pending = 0; + Map userStats = new HashMap<>(); + Map> todosMap = initiateTodosMap(); + + TodoStatistics dto = new TodoStatistics(total, completed, pending, userStats); + + try { + AggregationResults results = aggregateResults(request); + + for (TodoStatisticsAggregationRow r : results) { + total += r.getCount(); + if (Boolean.TRUE.equals(r.getCompleted())) { + completed += r.getCount(); + todosMap.get("completed").addAll(r.getTodos()); + } else { + pending += r.getCount(); + todosMap.get("pending").addAll(r.getTodos()); + } + userStats.merge(r.getUser(), Math.toIntExact(r.getCount()), Integer::sum); + } + + dto.setTotalTodos(total); + dto.setCompletedTodos(completed); + dto.setPendingTodos(pending); + dto.setUserStats(userStats); + + if (request.getFormat().equals(TodoStatisticsFormatEnum.detailed)) { + dto.setTodos(todosMap); + } + + return dto; + + } catch (Exception e) { + throw new RuntimeException("Error while fetching statistics", e); + } + } + + private void setRequestParams(TodoStatisticsRequest request) { + Instant from = request.getFrom(); + Instant to = request.getTo(); + + Instant finalTo = (to != null) ? to : Instant.now(); + Instant finalFrom = (from != null) ? from : finalTo.minus(7, ChronoUnit.DAYS); + + TodoStatisticsFormatEnum format = + Objects.requireNonNullElse(request.getFormat(), TodoStatisticsFormatEnum.summary); + request.setFrom(finalFrom); + request.setTo(finalTo); + request.setFormat(format); + } + + private Map> initiateTodosMap() { + Map> todosMap = new HashMap<>(); + todosMap.put("completed", new ArrayList<>()); + todosMap.put("pending", new ArrayList<>()); + + return todosMap; + } + + private AggregationResults aggregateResults( + TodoStatisticsRequest request) { + + Aggregation agg = + Aggregation.newAggregation( + Aggregation.match( + Criteria.where("createdAt").gte(request.getFrom()).lte(request.getTo())), + Aggregation.project("completed", "title", "createdBy", "createdAt", "completedAt"), + Aggregation.group("completed", "createdBy") + .count() + .as("count") + .push( + new BasicDBObject("_id", "$_id") + .append("title", "$title") + .append("createdBy", "$createdBy") + .append("createdAt", "$createdAt") + .append("completedAt", "$completedAt")) + .as("todos"), + Aggregation.project("count", "todos") + .and("_id.completed") + .as("completed") + .and("_id.createdBy") + .as("user")); + + return mongoTemplate.aggregate(agg, "todos", TodoStatisticsAggregationRow.class); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatistics.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatistics.java new file mode 100644 index 0000000..2ab1ad4 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatistics.java @@ -0,0 +1,73 @@ +package lv.ctco.springboottemplate.features.todo_statistics.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import lv.ctco.springboottemplate.features.todo.Todo; + +/** Statistics Data Transfer Object for {@link Todo} items */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TodoStatistics { + @Schema(description = "Total number of todo items") + private Long totalTodos; + + @Schema(description = "Number of completed items") + private Long completedTodos; + + @Schema(description = "Number of pending items") + private Long pendingTodos; + + @Schema(description = "Total number of todos per user") + private Map userStats; + + @Schema(description = "Detailed todos grouped by status") + private Map> todos; + + public TodoStatistics(long total, long completed, long pending, Map userStats) { + this.totalTodos = total; + this.completedTodos = completed; + this.pendingTodos = pending; + this.userStats = userStats; + } + + public Long getTotalTodos() { + return totalTodos; + } + + public void setTotalTodos(Long totalTodos) { + this.totalTodos = totalTodos; + } + + public Long getCompletedTodos() { + return completedTodos; + } + + public void setCompletedTodos(Long completedTodos) { + this.completedTodos = completedTodos; + } + + public Long getPendingTodos() { + return pendingTodos; + } + + public void setPendingTodos(Long pendingTodos) { + this.pendingTodos = pendingTodos; + } + + public Map getUserStats() { + return userStats; + } + + public void setUserStats(Map userStats) { + this.userStats = userStats; + } + + public Map> getTodos() { + return todos; + } + + public void setTodos(Map> todos) { + this.todos = todos; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsAggregationRow.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsAggregationRow.java new file mode 100644 index 0000000..72e42d2 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsAggregationRow.java @@ -0,0 +1,45 @@ +package lv.ctco.springboottemplate.features.todo_statistics.dto; + +import java.util.ArrayList; +import java.util.List; + +public class TodoStatisticsAggregationRow { + private String user; + private Boolean completed; + private long count; + private List todos; + + public TodoStatisticsAggregationRow() {} + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public Boolean getCompleted() { + return completed; + } + + public void setCompleted(Boolean completed) { + this.completed = completed; + } + + public long getCount() { + return count; + } + + public void setCount(long count) { + this.count = count; + } + + public List getTodos() { + return todos; + } + + public void setTodos(ArrayList todos) { + this.todos = todos; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java new file mode 100644 index 0000000..913641e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java @@ -0,0 +1,42 @@ +package lv.ctco.springboottemplate.features.todo_statistics.dto; + +import io.swagger.v3.oas.annotations.Parameter; +import java.time.Instant; +import lv.ctco.springboottemplate.features.todo_statistics.TodoStatisticsFormatEnum; +import lv.ctco.springboottemplate.features.todo_statistics.validators.TodoStatisticsValidDateRange; + +@TodoStatisticsValidDateRange +public class TodoStatisticsRequest { + @Parameter(example = "2024-03-15T10:30:00Z") + private Instant from; + + @Parameter(example = "2024-03-20T23:59:59Z") + private Instant to; + + @Parameter(example = "summary") + private TodoStatisticsFormatEnum format; + + public Instant getFrom() { + return from; + } + + public void setFrom(Instant from) { + this.from = from; + } + + public Instant getTo() { + return to; + } + + public void setTo(Instant to) { + this.to = to; + } + + public TodoStatisticsFormatEnum getFormat() { + return format; + } + + public void setFormat(TodoStatisticsFormatEnum format) { + this.format = format; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/Todos.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/Todos.java new file mode 100644 index 0000000..fc73d6e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/Todos.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.todo_statistics.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import lv.ctco.springboottemplate.features.todo.Todo; + +/** Detailed Statistics Data Transfer Object for {@link Todo} items */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record Todos( + String id, String title, String createdBy, Instant createdAt, Instant completedAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsDateRangeValidator.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsDateRangeValidator.java new file mode 100644 index 0000000..462e83e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsDateRangeValidator.java @@ -0,0 +1,26 @@ +package lv.ctco.springboottemplate.features.todo_statistics.validators; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.Instant; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatisticsRequest; + +public class TodoStatisticsDateRangeValidator + implements ConstraintValidator { + + @Override + public boolean isValid(TodoStatisticsRequest request, ConstraintValidatorContext context) { + Instant from = request.getFrom(); + Instant to = request.getTo(); + + if (from == null && to == null) { + return false; + } + + if (from != null && to != null) { + return !from.isAfter(to); + } + + return true; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java new file mode 100644 index 0000000..20eb8f4 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java @@ -0,0 +1,18 @@ +package lv.ctco.springboottemplate.features.todo_statistics.validators; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TodoStatisticsDateRangeValidator.class) +@Documented +public @interface TodoStatisticsValidDateRange { + String message() default + "Either from, to, or both can pe provided. 'from' date must be before or equal to 'to' date"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java new file mode 100644 index 0000000..6b7eba5 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java @@ -0,0 +1,18 @@ +package lv.ctco.springboottemplate.features.todo_statistics.validators; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.*; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TodoStatisticsValidFormatValidator.class) +public @interface TodoStatisticsValidFormat { + String message() default "must be any of {enumClass}"; + + Class> enumClass(); + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java new file mode 100644 index 0000000..df2a157 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java @@ -0,0 +1,22 @@ +package lv.ctco.springboottemplate.features.todo_statistics.validators; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; + +public class TodoStatisticsValidFormatValidator + implements ConstraintValidator { + private Class> enumClass; + + @Override + public void initialize(TodoStatisticsValidFormat constraintAnnotation) { + this.enumClass = constraintAnnotation.enumClass(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return true; + return Arrays.stream(enumClass.getEnumConstants()) + .anyMatch(e -> e.name().equalsIgnoreCase(value)); + } +} diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java new file mode 100644 index 0000000..0ee8286 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java @@ -0,0 +1,100 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; +import org.junit.jupiter.api.BeforeEach; +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.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +class TodoStatisticsControllerIT { + + @Container static final MongoDBContainer mongo = new MongoDBContainer("mongo:7.0"); + + @DynamicPropertySource + static void configure(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl); + } + + @Autowired private MockMvc mockMvc; + + @Autowired private TodoRepository todoRepository; + + @Autowired private ObjectMapper objectMapper; + + @BeforeEach + void setup() { + todoRepository.deleteAll(); + + Instant now = Instant.now(); + + var todos = + List.of( + new Todo( + null, + "Buy groceries", + "Milk, eggs, bread", + false, + "user3", + "user3", + now, + null, + null), + new Todo( + null, + "Call dentist", + "Schedule annual checkup", + true, + "user1", + "system", + now, + now, + now), + new Todo( + null, + "Fix bug in production", + "High priority issue #123", + false, + "user1", + "user3", + now, + null, + null)); + + todoRepository.saveAll(todos); + } + + @Test + void shouldReturnStatistics() throws Exception { + mockMvc + .perform( + get("/api/statistics") + .param("from", Instant.now().minusSeconds(3600).toString()) + .param("to", Instant.now().plusSeconds(3600).toString()) + .param("format", "detailed") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalTodos").value(3)) + .andExpect(jsonPath("$.completedTodos").value(1)) + .andExpect(jsonPath("$.pendingTodos").value(2)) + .andExpect(jsonPath("$.userStats.user1").value(2)) + .andExpect(jsonPath("$.todos.completed").isArray()); + } +} From fb3a6b5ae38f203055a0d325b145539143d92c3a Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 11:59:56 +0300 Subject: [PATCH 2/7] feat(task 2): add tests, refactor enum --- .../errorhandling/ErrorMessages.java | 6 + .../ExceptionHandler.java} | 12 +- .../TodoStatisticsFormatEnum.java | 24 ++- .../TodoStatisticsFormatEnumConverter.java | 17 ++ .../TodoStatisticsService.java | 4 +- .../TodoStatisticsValidDateRange.java | 5 +- .../validators/TodoStatisticsValidFormat.java | 18 -- .../TodoStatisticsValidFormatValidator.java | 22 --- .../features/todo/TodoRepositoryTest.java | 95 ++++++++++ .../TodoStatisticsControllerTest.java | 179 ++++++++++++------ 10 files changed, 275 insertions(+), 107 deletions(-) create mode 100644 src/main/java/lv/ctco/springboottemplate/errorhandling/ErrorMessages.java rename src/main/java/lv/ctco/springboottemplate/{features/todo_statistics/TodoStatisticsExceptionHandler.java => errorhandling/ExceptionHandler.java} (82%) create mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnumConverter.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java delete mode 100644 src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java create mode 100644 src/test/java/lv/ctco/springboottemplate/features/todo/TodoRepositoryTest.java diff --git a/src/main/java/lv/ctco/springboottemplate/errorhandling/ErrorMessages.java b/src/main/java/lv/ctco/springboottemplate/errorhandling/ErrorMessages.java new file mode 100644 index 0000000..0f71435 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/errorhandling/ErrorMessages.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.errorhandling; + +public final class ErrorMessages { + public static final String TODO_STATS_DATE_RANGE_INVALID = + "Either from, to, or both can be provided. 'from' date must be before or equal to 'to' date"; +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/errorhandling/ExceptionHandler.java similarity index 82% rename from src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java rename to src/main/java/lv/ctco/springboottemplate/errorhandling/ExceptionHandler.java index 16e67f0..d6ae12f 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/errorhandling/ExceptionHandler.java @@ -1,4 +1,4 @@ -package lv.ctco.springboottemplate.features.todo_statistics; +package lv.ctco.springboottemplate.errorhandling; import java.util.ArrayList; import java.util.List; @@ -8,12 +8,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice -public class TodoStatisticsExceptionHandler { - @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) +public class ExceptionHandler { + @org.springframework.web.bind.annotation.ExceptionHandler({ + MethodArgumentNotValidException.class, + BindException.class + }) public ResponseEntity> handleValidationExceptions(Exception ex) { List errors = new ArrayList<>(); @@ -33,7 +35,7 @@ public ResponseEntity> handleValidationExceptions(Exception .body(Map.of("status", 400, "error", "Bad Request", "messages", errors)); } - @ExceptionHandler(RuntimeException.class) + @org.springframework.web.bind.annotation.ExceptionHandler(RuntimeException.class) public ResponseEntity> handleServerError(RuntimeException ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of("error", "Internal server error", "details", ex.getMessage())); diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java index 152706c..7f02253 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnum.java @@ -1,13 +1,31 @@ package lv.ctco.springboottemplate.features.todo_statistics; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; public enum TodoStatisticsFormatEnum { - summary, - detailed; + SUMMARY("summary"), + DETAILED("detailed"); + + private final String value; + + TodoStatisticsFormatEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } @JsonCreator public static TodoStatisticsFormatEnum fromString(String value) { - return TodoStatisticsFormatEnum.valueOf(value.toLowerCase()); + for (TodoStatisticsFormatEnum format : TodoStatisticsFormatEnum.values()) { + if (format.value.equalsIgnoreCase(value)) { + return format; + } + } + throw new IllegalArgumentException( + "Unknown format: " + value + "must be summary, detailed or empty"); } } diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnumConverter.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnumConverter.java new file mode 100644 index 0000000..1727283 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsFormatEnumConverter.java @@ -0,0 +1,17 @@ +package lv.ctco.springboottemplate.features.todo_statistics; + +import com.mongodb.lang.Nullable; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class TodoStatisticsFormatEnumConverter + implements Converter { + @Override + public TodoStatisticsFormatEnum convert(@Nullable String source) { + if (source == null || source.isBlank()) { + return TodoStatisticsFormatEnum.SUMMARY; + } + return TodoStatisticsFormatEnum.fromString(source); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java index 4951df3..093e4b9 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsService.java @@ -53,7 +53,7 @@ public TodoStatistics makeStatistics(TodoStatisticsRequest request) { dto.setPendingTodos(pending); dto.setUserStats(userStats); - if (request.getFormat().equals(TodoStatisticsFormatEnum.detailed)) { + if (request.getFormat().equals(TodoStatisticsFormatEnum.DETAILED)) { dto.setTodos(todosMap); } @@ -72,7 +72,7 @@ private void setRequestParams(TodoStatisticsRequest request) { Instant finalFrom = (from != null) ? from : finalTo.minus(7, ChronoUnit.DAYS); TodoStatisticsFormatEnum format = - Objects.requireNonNullElse(request.getFormat(), TodoStatisticsFormatEnum.summary); + Objects.requireNonNullElse(request.getFormat(), TodoStatisticsFormatEnum.SUMMARY); request.setFrom(finalFrom); request.setTo(finalTo); request.setFormat(format); diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java index 20eb8f4..856d764 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidDateRange.java @@ -1,5 +1,7 @@ package lv.ctco.springboottemplate.features.todo_statistics.validators; +import static lv.ctco.springboottemplate.errorhandling.ErrorMessages.TODO_STATS_DATE_RANGE_INVALID; + import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @@ -9,8 +11,7 @@ @Constraint(validatedBy = TodoStatisticsDateRangeValidator.class) @Documented public @interface TodoStatisticsValidDateRange { - String message() default - "Either from, to, or both can pe provided. 'from' date must be before or equal to 'to' date"; + String message() default TODO_STATS_DATE_RANGE_INVALID; Class[] groups() default {}; diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java deleted file mode 100644 index 6b7eba5..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormat.java +++ /dev/null @@ -1,18 +0,0 @@ -package lv.ctco.springboottemplate.features.todo_statistics.validators; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; -import java.lang.annotation.*; - -@Target({ElementType.FIELD}) -@Retention(RetentionPolicy.RUNTIME) -@Constraint(validatedBy = TodoStatisticsValidFormatValidator.class) -public @interface TodoStatisticsValidFormat { - String message() default "must be any of {enumClass}"; - - Class> enumClass(); - - Class[] groups() default {}; - - Class[] payload() default {}; -} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java deleted file mode 100644 index df2a157..0000000 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/validators/TodoStatisticsValidFormatValidator.java +++ /dev/null @@ -1,22 +0,0 @@ -package lv.ctco.springboottemplate.features.todo_statistics.validators; - -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; -import java.util.Arrays; - -public class TodoStatisticsValidFormatValidator - implements ConstraintValidator { - private Class> enumClass; - - @Override - public void initialize(TodoStatisticsValidFormat constraintAnnotation) { - this.enumClass = constraintAnnotation.enumClass(); - } - - @Override - public boolean isValid(String value, ConstraintValidatorContext context) { - if (value == null) return true; - return Arrays.stream(enumClass.getEnumConstants()) - .anyMatch(e -> e.name().equalsIgnoreCase(value)); - } -} diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoRepositoryTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoRepositoryTest.java new file mode 100644 index 0000000..7133e67 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoRepositoryTest.java @@ -0,0 +1,95 @@ +package lv.ctco.springboottemplate.features.todo; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataMongoTest +@Testcontainers +class TodoRepositoryTest { + + Instant now = Instant.now(); + List todos = + List.of( + new Todo("1", "Testingsss", "test todo", false, "user1", "user2", now, now, null), + new Todo("2", "something", "another test", true, "user2", "user3", now, now, now), + new Todo( + "3", + "do the necessary", + "live and maintain", + false, + "user5", + "user1", + now, + now, + null)); + + @Autowired private TodoRepository todoRepository; + + @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:7.0.5"); + + @DynamicPropertySource + static void setProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + } + + @BeforeEach + void setup() { + todoRepository.deleteAll(); + todoRepository.saveAll(todos); + } + + @Test + void shouldSaveAndFindTodo() { + List result = todoRepository.findByTitleContainingIgnoreCase("test"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).title()).isEqualTo("Testingsss"); + } + + @Test + void shouldReturnEmptyListWhenNoMatch() { + List result = todoRepository.findByTitleContainingIgnoreCase("aaaaaaaaaaa"); + assertThat(result).isEmpty(); + } + + // I doubt these tests need to be added, but still + @Test + void shouldSaveAndFindById() { + Optional found = todoRepository.findById(todos.get(0).id()); + + assertThat(found).isNotNull(); + assertThat(found.get().title()).isEqualTo("Testingsss"); + } + + @Test + void shouldInsertMultipleTodos() { + todoRepository.deleteAll(); + + List bulkTodos = List.of(todos.get(0), todos.get(1)); + + todoRepository.insert(bulkTodos); + + assertThat(todoRepository.count()).isEqualTo(2); + } + + @Test + void shouldDeleteById() { + + todoRepository.deleteById(todos.get(1).id()); + + assertThat(todoRepository.existsById(todos.get(1).id())).isFalse(); + assertThat(todoRepository.existsById(todos.get(0).id())).isTrue(); + } +} diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java index 0ee8286..1e87ee1 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java @@ -1,80 +1,81 @@ package lv.ctco.springboottemplate.features.todo_statistics; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static lv.ctco.springboottemplate.errorhandling.ErrorMessages.TODO_STATS_DATE_RANGE_INVALID; +import static org.assertj.core.api.Assertions.assertThat; -import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import lv.ctco.springboottemplate.features.todo.Todo; import lv.ctco.springboottemplate.features.todo.TodoRepository; +import lv.ctco.springboottemplate.features.todo_statistics.dto.TodoStatistics; import org.junit.jupiter.api.BeforeEach; 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.http.MediaType; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; import org.testcontainers.containers.MongoDBContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -@SpringBootTest -@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers -class TodoStatisticsControllerIT { +class TodoStatisticsControllerTest { - @Container static final MongoDBContainer mongo = new MongoDBContainer("mongo:7.0"); + @Container static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0"); - @DynamicPropertySource - static void configure(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl); - } - - @Autowired private MockMvc mockMvc; + private final TestRestTemplate restTemplate; + private final int port; + private final TodoRepository todoRepository; - @Autowired private TodoRepository todoRepository; + @Autowired + public TodoStatisticsControllerTest( + TestRestTemplate restTemplate, @LocalServerPort int port, TodoRepository todoRepository) { + this.restTemplate = restTemplate; + this.port = port; + this.todoRepository = todoRepository; + } - @Autowired private ObjectMapper objectMapper; + private String baseUrl() { + return "http://localhost:" + port + "/api/statistics"; + } @BeforeEach - void setup() { + void setUp() { todoRepository.deleteAll(); - Instant now = Instant.now(); - - var todos = + List todos = List.of( new Todo( null, - "Buy groceries", - "Milk, eggs, bread", + "Buy milk", + "Get 2 liters of milk", false, - "user3", - "user3", - now, + "alice", + null, + Instant.now(), null, null), new Todo( null, - "Call dentist", - "Schedule annual checkup", + "Finish project", + "Complete the spring boot module", true, - "user1", - "system", - now, - now, - now), + "bob", + "charlie", + Instant.now().minus(3, ChronoUnit.DAYS), + Instant.now().minus(1, ChronoUnit.DAYS), + Instant.now().minus(1, ChronoUnit.DAYS)), new Todo( null, - "Fix bug in production", - "High priority issue #123", + "Read book", + "Read 'Effective Java'", false, - "user1", - "user3", - now, + "alice", + null, + Instant.now().minus(5, ChronoUnit.DAYS), null, null)); @@ -82,19 +83,87 @@ void setup() { } @Test - void shouldReturnStatistics() throws Exception { - mockMvc - .perform( - get("/api/statistics") - .param("from", Instant.now().minusSeconds(3600).toString()) - .param("to", Instant.now().plusSeconds(3600).toString()) - .param("format", "detailed") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalTodos").value(3)) - .andExpect(jsonPath("$.completedTodos").value(1)) - .andExpect(jsonPath("$.pendingTodos").value(2)) - .andExpect(jsonPath("$.userStats.user1").value(2)) - .andExpect(jsonPath("$.todos.completed").isArray()); + void shouldReturnStatistics() { + Instant from = Instant.now().minus(2, ChronoUnit.DAYS); + Instant to = Instant.now(); + + String url = + String.format("%s?format=summary&from=%s&to=%s", baseUrl(), from.toString(), to.toString()); + + ResponseEntity response = restTemplate.getForEntity(url, TodoStatistics.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + TodoStatistics stats = response.getBody(); + assertThat(stats).isNotNull(); + } + + @Test + void shouldReturnStatisticsWithMissingRequestParams() { + Instant from = Instant.now().minus(2, ChronoUnit.DAYS); + + String url = String.format("%s?from=%s", baseUrl(), from.toString()); + + ResponseEntity response = restTemplate.getForEntity(url, TodoStatistics.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + TodoStatistics stats = response.getBody(); + assertThat(stats).isNotNull(); + } + + @Test + void shouldIgnoreFormatCase() { + Instant from = Instant.now().minus(2, ChronoUnit.DAYS); + Instant to = Instant.now(); + + String url = + String.format( + "%s?format=DETAIled&from=%s&to=%s", baseUrl(), from.toString(), to.toString()); + + ResponseEntity response = restTemplate.getForEntity(url, TodoStatistics.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + TodoStatistics stats = response.getBody(); + assertThat(stats).isNotNull(); + } + + @Test + void shouldHandleEmptyResponse() { + Instant from = Instant.now().minus(101, ChronoUnit.DAYS); + Instant to = Instant.now().minus(100, ChronoUnit.DAYS); + + String url = String.format("%s?from=%s&to=%s", baseUrl(), from.toString(), to.toString()); + + ResponseEntity response = restTemplate.getForEntity(url, TodoStatistics.class); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + TodoStatistics stats = response.getBody(); + assertThat(stats).isNotNull(); + assertThat(stats.getTotalTodos()).isEqualTo(0); + } + + @Test + void shouldReturnErrorIfMissingParams() { + + ResponseEntity response = restTemplate.getForEntity(baseUrl(), String.class); + + assertThat(response.getStatusCode().is4xxClientError()).isTrue(); + assertThat(response.getBody()) + .withFailMessage( + "Expected response body to contain error message: %s", TODO_STATS_DATE_RANGE_INVALID) + .contains(TODO_STATS_DATE_RANGE_INVALID); + } + + @Test + void shouldReturnErrorIfInvalidFormat() { + Instant from = Instant.now().minus(2, ChronoUnit.DAYS); + Instant to = Instant.now(); + + String url = + String.format( + "%s?format=deta1led&from=%s&to=%s", baseUrl(), from.toString(), to.toString()); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode().is4xxClientError()).isTrue(); } } From fa47ed80da1b9a7186cea5dc9372e4f75d287b00 Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 15:22:30 +0300 Subject: [PATCH 3/7] fix(task 2): add DynamicPropertySource in test --- .../todo_statistics/TodoStatisticsControllerTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java index 1e87ee1..80e8ef9 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo_statistics/TodoStatisticsControllerTest.java @@ -16,6 +16,8 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.MongoDBContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -26,6 +28,11 @@ class TodoStatisticsControllerTest { @Container static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0"); + @DynamicPropertySource + static void mongoProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl); + } + private final TestRestTemplate restTemplate; private final int port; private final TodoRepository todoRepository; From 452d522a3c8b195d12203558e2c319bb3f32bef3 Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 16:34:13 +0300 Subject: [PATCH 4/7] feat(Arch Unit): whitelist framework hooks in UnusedMethodsTest --- .../architecture/UnusedMethodsTest.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java index 9a996ef..8131d77 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java @@ -12,6 +12,7 @@ import com.tngtech.archunit.lang.SimpleConditionEvent; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; import org.springframework.stereotype.Service; @@ -40,7 +41,7 @@ void injectable_beans_should_not_have_unused_methods() { @Override public void check(JavaClass item, ConditionEvents events) { item.getMethods().stream() - .filter(method -> !isSpringLifecycleMethod(method)) + .filter(method -> !isFrameworkLifecycleMethod(method)) .filter(method -> method.getAccessesToSelf().isEmpty()) .forEach( method -> @@ -53,11 +54,18 @@ public void check(JavaClass item, ConditionEvents events) { method.getName(), item.getName())))); } - private boolean isSpringLifecycleMethod(JavaMethod method) { - return method.getName().equals("init") - || method.getName().equals("destroy") - || method.getName().equals("afterPropertiesSet") - || method.getName().startsWith("set"); + private boolean isFrameworkLifecycleMethod(JavaMethod method) { + String name = method.getName(); + JavaClass owner = method.getOwner(); + + // Spring Converter + if (owner.isAssignableTo(Converter.class) && name.equals("convert")) { + return true; + } + + // ...other framework hooks + + return false; } }) .because( From 56eb21037b02702a75eddb1b1aba26b2e4470717 Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 17:20:17 +0300 Subject: [PATCH 5/7] fix(Arch Unit): properly merge with main --- .../architecture/UnusedMethodsTest.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java index 8131d77..f5617d4 100644 --- a/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java +++ b/src/test/java/lv/ctco/springboottemplate/architecture/UnusedMethodsTest.java @@ -41,7 +41,7 @@ void injectable_beans_should_not_have_unused_methods() { @Override public void check(JavaClass item, ConditionEvents events) { item.getMethods().stream() - .filter(method -> !isFrameworkLifecycleMethod(method)) + .filter(method -> !isSpringLifecycleMethod(method)) .filter(method -> method.getAccessesToSelf().isEmpty()) .forEach( method -> @@ -54,18 +54,15 @@ public void check(JavaClass item, ConditionEvents events) { method.getName(), item.getName())))); } - private boolean isFrameworkLifecycleMethod(JavaMethod method) { + private boolean isSpringLifecycleMethod(JavaMethod method) { String name = method.getName(); JavaClass owner = method.getOwner(); - // Spring Converter - if (owner.isAssignableTo(Converter.class) && name.equals("convert")) { - return true; - } - - // ...other framework hooks - - return false; + return name.equals("init") + || name.equals("destroy") + || name.equals("afterPropertiesSet") + || name.startsWith("set") + || owner.isAssignableTo(Converter.class) && name.equals("convert"); } }) .because( From 5494ce0dc98f9503d58bac323df9d031a4d3e70b Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 17:29:07 +0300 Subject: [PATCH 6/7] fix: additional checks on completedAt in TodoServiceIntegrationTest --- .../features/todo/TodoService.java | 12 +++++++++++- .../features/todo/TodoServiceIntegrationTest.java | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java index 73f9b95..4537f01 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -50,7 +50,17 @@ public List searchTodos(String title) { public Todo createTodo(String title, String description, boolean completed, String createdBy) { var now = Instant.now(); - var todo = new Todo(null, title, description, completed, createdBy, createdBy, now, now, null); + var todo = + new Todo( + null, + title, + description, + completed, + createdBy, + createdBy, + now, + now, + completed ? Instant.now() : null); return todoRepository.save(todo); } diff --git a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java index 7a98397..bc2d792 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java @@ -63,6 +63,7 @@ void shouldCreateAndRetrieveTodo() { assertThat(todo.updatedBy()).isEqualTo(createdBy); assertThat(todo.createdAt()).isNotNull(); assertThat(todo.updatedAt()).isNotNull(); + assertThat(todo.completedAt()).isNull(); } @Test @@ -84,6 +85,7 @@ void shouldUpdateTodo() { assertThat(updated.createdBy()).isEqualTo("creator"); assertThat(updated.updatedBy()).isEqualTo("updater"); assertThat(updated.updatedAt()).isAfter(created.updatedAt()); + assertThat(updated.completedAt()).isNotNull(); } @Test From 45532c8dbda8c656904e2842050fb555c2c725cf Mon Sep 17 00:00:00 2001 From: Olga Zoldaka Date: Fri, 5 Sep 2025 17:59:29 +0300 Subject: [PATCH 7/7] fix: minor corrections --- .../features/todo_statistics/dto/TodoStatisticsRequest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java index 913641e..34aa872 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo_statistics/dto/TodoStatisticsRequest.java @@ -7,10 +7,10 @@ @TodoStatisticsValidDateRange public class TodoStatisticsRequest { - @Parameter(example = "2024-03-15T10:30:00Z") + @Parameter(example = "2025-09-01T10:30:00Z") private Instant from; - @Parameter(example = "2024-03-20T23:59:59Z") + @Parameter(example = "2025-10-01T23:59:59Z") private Instant to; @Parameter(example = "summary")