diff --git a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java index ec9cb49..933e5ab 100644 --- a/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java +++ b/src/main/java/lv/ctco/springboottemplate/exception/GlobalExceptionHandler.java @@ -1,16 +1,64 @@ package lv.ctco.springboottemplate.exception; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +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.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; @RestControllerAdvice public class GlobalExceptionHandler { + private final MessageSource messageSource; + + public GlobalExceptionHandler(MessageSource messageSource) { + this.messageSource = messageSource; + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException ex) { + HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value()); + HttpStatus effectiveStatus = status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR; + String reason = ex.getReason() != null ? ex.getReason() : message("errors.request.failed"); + + return ResponseEntity.status(effectiveStatus).body(reason); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity handleBindException(BindException ex) { + String message = + ex.getBindingResult().getAllErrors().stream() + .findFirst() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse(message("errors.invalid.request.parameters")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + String message = + ex.getConstraintViolations().stream() + .findFirst() + .map(violation -> violation.getMessage()) + .orElse(message("errors.invalid.request.parameters")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body("Oops, something went wrong, try later"); + .body(message("errors.unexpected")); + } + + private String message(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); } } 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..eeddaac --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsController.java @@ -0,0 +1,41 @@ +package lv.ctco.springboottemplate.features.statistics; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.LocalDate; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequestDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Statistics Controller", description = "Todo statistics endpoints") +public class StatisticsController { + + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation(summary = "Get todo statistics") + public ResponseEntity getStatistics(@Valid @ModelAttribute StatisticsRequestDto input) { + LocalDate fromDate = input.fromDate(); + LocalDate toDate = input.toDate(); + + if ("summary".equalsIgnoreCase(input.normalizedFormat())) { + StatisticsSummaryDto summary = statisticsService.getSummary(fromDate, toDate); + return ResponseEntity.ok(summary); + } + + StatisticsDetailedDto detailed = statisticsService.getDetailed(fromDate, toDate); + return ResponseEntity.ok(detailed); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java new file mode 100644 index 0000000..6386d41 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsMapperService.java @@ -0,0 +1,84 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodoItemDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import org.bson.Document; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.stereotype.Service; + +@Service +public class StatisticsMapperService { + + public StatisticsSummaryDto toSummary(AggregationResults results) { + long total = 0L; + long completed = 0L; + Map userStats = new HashMap<>(); + + for (Document doc : results) { + AggregatedRow row = toAggregatedRow(doc); + + total += row.count(); + if (row.completed()) { + completed += row.count(); + } + userStats.merge(row.createdBy(), row.count(), Long::sum); + } + + long pending = total - completed; + return new StatisticsSummaryDto(total, completed, pending, userStats); + } + + public StatisticsDetailedDto toDetailed( + StatisticsSummaryDto summary, StatisticsTodosDto todosDto) { + return new StatisticsDetailedDto( + summary.totalTodos(), + summary.completedTodos(), + summary.pendingTodos(), + summary.userStats(), + todosDto); + } + + public StatisticsTodosDto toTodosDto(List todos) { + List completedTodos = + todos.stream() + .filter(Todo::completed) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), + todo.title(), + todo.createdBy(), + todo.createdAt(), + todo.updatedAt())) + .toList(); + + List pendingTodos = + todos.stream() + .filter(todo -> !todo.completed()) + .map( + todo -> + new StatisticsTodoItemDto( + todo.id(), todo.title(), todo.createdBy(), todo.createdAt(), null)) + .toList(); + + return new StatisticsTodosDto(completedTodos, pendingTodos); + } + + private AggregatedRow toAggregatedRow(Document doc) { + Document id = (Document) doc.get("_id"); + String createdBy = id.getString("createdBy"); + boolean isCompleted = Boolean.TRUE.equals(id.getBoolean("completed")); + Number countNumber = doc.get("count", Number.class); + long count = countNumber != null ? countNumber.longValue() : 0L; + + return new AggregatedRow(createdBy, isCompleted, count); + } + + private record AggregatedRow(String createdBy, boolean completed, long count) {} +} 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..fe6a38b --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsService.java @@ -0,0 +1,98 @@ +package lv.ctco.springboottemplate.features.statistics; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoService; +import org.bson.Document; +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.aggregation.GroupOperation; +import org.springframework.data.mongodb.core.aggregation.MatchOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Service; + +@Service +public class StatisticsService { + + private final TodoService todoService; + private final MongoTemplate mongoTemplate; + private final StatisticsMapperService statisticsMapperService; + + public StatisticsService( + TodoService todoService, + MongoTemplate mongoTemplate, + StatisticsMapperService statisticsMapperService) { + this.todoService = todoService; + this.mongoTemplate = mongoTemplate; + this.statisticsMapperService = statisticsMapperService; + } + + public StatisticsSummaryDto getSummary(LocalDate from, LocalDate to) { + return aggregateSummary(from, to); + } + + public StatisticsDetailedDto getDetailed(LocalDate from, LocalDate to) { + StatisticsSummaryDto summary = aggregateSummary(from, to); + List todos = findTodosInRange(from, to); + StatisticsTodosDto todosDto = statisticsMapperService.toTodosDto(todos); + return statisticsMapperService.toDetailed(summary, todosDto); + } + + private StatisticsSummaryDto aggregateSummary(LocalDate from, LocalDate to) { + Aggregation aggregation = buildAggregation(from, to); + AggregationResults results = + mongoTemplate.aggregate(aggregation, "todos", Document.class); + return statisticsMapperService.toSummary(results); + } + + private Aggregation buildAggregation(LocalDate from, LocalDate to) { + Criteria dateCriteria = buildDateCriteria(from, to); + + GroupOperation groupByUserAndCompletion = + Aggregation.group("createdBy", "completed").count().as("count"); + + if (dateCriteria != null) { + MatchOperation match = Aggregation.match(dateCriteria); + return Aggregation.newAggregation(match, groupByUserAndCompletion); + } + + return Aggregation.newAggregation(groupByUserAndCompletion); + } + + private List findTodosInRange(LocalDate from, LocalDate to) { + Instant fromInstant = from != null ? atStartOfDay(from) : null; + Instant toInstant = to != null ? atEndOfDay(to) : null; + + return todoService.getTodosByCreatedAtRange(fromInstant, toInstant); + } + + private Criteria buildDateCriteria(LocalDate from, LocalDate to) { + Instant fromInstant = from != null ? atStartOfDay(from) : null; + Instant toInstant = to != null ? atEndOfDay(to) : null; + + if (fromInstant != null && toInstant != null) { + return Criteria.where("createdAt").gte(fromInstant).lte(toInstant); + } else if (fromInstant != null) { + return Criteria.where("createdAt").gte(fromInstant); + } else if (toInstant != null) { + return Criteria.where("createdAt").lte(toInstant); + } + + return null; + } + + private Instant atStartOfDay(LocalDate date) { + return date.atStartOfDay().toInstant(ZoneOffset.UTC); + } + + private Instant atEndOfDay(LocalDate date) { + return date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java new file mode 100644 index 0000000..cdf1f7e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedDto.java @@ -0,0 +1,10 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsDetailedDto( + long totalTodos, + long completedTodos, + long pendingTodos, + Map userStats, + StatisticsTodosDto todos) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java new file mode 100644 index 0000000..0513459 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequestDto.java @@ -0,0 +1,28 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import lv.ctco.springboottemplate.validation.statistics.ValidStatisticsRequest; + +@ValidStatisticsRequest +public record StatisticsRequestDto( + String from, + String to, + @Pattern( + regexp = "^(summary|detailed)$", + flags = Pattern.Flag.CASE_INSENSITIVE, + message = "{statistics.format.invalid}") + String format) { + + public LocalDate fromDate() { + return from != null && !from.isBlank() ? LocalDate.parse(from) : null; + } + + public LocalDate toDate() { + return to != null && !to.isBlank() ? LocalDate.parse(to) : null; + } + + public String normalizedFormat() { + return format == null ? "summary" : format; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java new file mode 100644 index 0000000..3c36c68 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsSummaryDto( + long totalTodos, long completedTodos, long pendingTodos, Map userStats) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java new file mode 100644 index 0000000..9ecaf16 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodoItemDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.Instant; + +public record StatisticsTodoItemDto( + String id, String title, String createdBy, Instant createdAt, Instant completedAt) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java new file mode 100644 index 0000000..d536ea6 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsTodosDto.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record StatisticsTodosDto( + List completed, List pending) {} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java index 961b95d..1bc6771 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoRepository.java @@ -1,5 +1,6 @@ package lv.ctco.springboottemplate.features.todo; +import java.time.Instant; import java.util.List; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; @@ -27,4 +28,10 @@ @Repository public interface TodoRepository extends MongoRepository { List findByTitleContainingIgnoreCase(String title); + + List findByCreatedAtBetween(Instant from, Instant to); + + List findByCreatedAtGreaterThanEqual(Instant from); + + List findByCreatedAtLessThanEqual(Instant to); } 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..a7a378e 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoService.java @@ -81,4 +81,16 @@ public boolean deleteTodo(String id) { } return false; } + + public List getTodosByCreatedAtRange(Instant from, Instant to) { + if (from != null && to != null) { + return todoRepository.findByCreatedAtBetween(from, to); + } else if (from != null) { + return todoRepository.findByCreatedAtGreaterThanEqual(from); + } else if (to != null) { + return todoRepository.findByCreatedAtLessThanEqual(to); + } + + return todoRepository.findAll(); + } } diff --git a/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java new file mode 100644 index 0000000..6ec4b03 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequest.java @@ -0,0 +1,19 @@ +package lv.ctco.springboottemplate.validation.statistics; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidStatisticsRequestValidator.class) +public @interface ValidStatisticsRequest { + String message() default "{statistics.request.invalid}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java new file mode 100644 index 0000000..100078a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/validation/statistics/ValidStatisticsRequestValidator.java @@ -0,0 +1,73 @@ +package lv.ctco.springboottemplate.validation.statistics; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequestDto; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; + +public class ValidStatisticsRequestValidator + implements ConstraintValidator { + + private final MessageSource messageSource; + + public ValidStatisticsRequestValidator(MessageSource messageSource) { + this.messageSource = messageSource; + } + + @Override + public boolean isValid(StatisticsRequestDto value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + LocalDate fromDate = parseDate(value.from(), "from", context); + LocalDate toDate = parseDate(value.to(), "to", context); + + if (fromDate == null && hasValue(value.from())) { + return false; + } + if (toDate == null && hasValue(value.to())) { + return false; + } + + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + addViolation(context, message("statistics.range.invalid"), "from"); + return false; + } + + return true; + } + + private LocalDate parseDate(String raw, String field, ConstraintValidatorContext context) { + if (!hasValue(raw)) { + return null; + } + + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException ex) { + addViolation(context, message("statistics.date.invalid", field), field); + return null; + } + } + + private void addViolation( + ConstraintValidatorContext context, String message, String propertyName) { + context.disableDefaultConstraintViolation(); + context + .buildConstraintViolationWithTemplate(message) + .addPropertyNode(propertyName) + .addConstraintViolation(); + } + + private boolean hasValue(String value) { + return value != null && !value.isBlank(); + } + + private String message(String code, Object... args) { + return messageSource.getMessage(code, args, LocaleContextHolder.getLocale()); + } +} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..da235d7 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,7 @@ +statistics.request.invalid=Invalid statistics request +statistics.format.invalid=format must be either 'summary' or 'detailed' +statistics.date.invalid=Invalid '{0}' date. Expected format is yyyy-MM-dd. +statistics.range.invalid='from' date must be before or equal to 'to' date +errors.request.failed=Request failed +errors.invalid.request.parameters=Invalid request parameters +errors.unexpected=Oops, something went wrong, try later 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..1790773 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsControllerIntegrationTest.java @@ -0,0 +1,78 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +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.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +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); + } + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate restTemplate; + + @Autowired private TodoRepository todoRepository; + + @BeforeEach + void setup() { + todoRepository.deleteAll(); + todoRepository.save( + new Todo( + null, + "Sample", + "Sample desc", + false, + "user1", + "user1", + Instant.parse("2024-01-10T10:00:00Z"), + Instant.parse("2024-01-10T10:00:00Z"))); + } + + @Test + void should_return_summary_statistics_via_http() { + String url = "http://localhost:" + port + "/api/statistics?format=summary"; + + ResponseEntity response = + restTemplate.getForEntity(url, StatisticsSummaryDto.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + StatisticsSummaryDto body = response.getBody(); + assertThat(body).isNotNull(); + assertThat(body.totalTodos()).isEqualTo(1); + assertThat(body.pendingTodos()).isEqualTo(1); + assertThat(body.completedTodos()).isZero(); + } + + @Test + void should_return_bad_request_for_invalid_date() { + String url = "http://localhost:" + port + "/api/statistics?from=2024-99-99"; + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java new file mode 100644 index 0000000..ce2c003 --- /dev/null +++ b/src/test/java/lv/ctco/springboottemplate/features/statistics/StatisticsServiceIntegrationTest.java @@ -0,0 +1,154 @@ +package lv.ctco.springboottemplate.features.statistics; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.LocalDate; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsDetailedDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryDto; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsTodosDto; +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.boot.test.context.SpringBootTest; +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) +class StatisticsServiceIntegrationTest { + + @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 TodoRepository todoRepository; + private final StatisticsService statisticsService; + + StatisticsServiceIntegrationTest( + TodoRepository todoRepository, StatisticsService statisticsService) { + this.todoRepository = todoRepository; + this.statisticsService = statisticsService; + } + + @BeforeEach + void clean() { + todoRepository.deleteAll(); + } + + @Test + void should_calculate_summary_without_filters() { + // given + todoRepository.save( + new Todo( + null, + "Todo 1", + "First", + false, + "user1", + "user1", + Instant.parse("2024-01-10T10:00:00Z"), + Instant.parse("2024-01-10T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "Todo 2", + "Second", + true, + "user1", + "user1", + Instant.parse("2024-01-11T10:00:00Z"), + Instant.parse("2024-01-12T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "Todo 3", + "Third", + false, + "user2", + "user2", + Instant.parse("2024-01-12T10:00:00Z"), + Instant.parse("2024-01-12T10:00:00Z"))); + + // when + StatisticsSummaryDto summary = statisticsService.getSummary(null, null); + + // then + assertThat(summary.totalTodos()).isEqualTo(3); + assertThat(summary.completedTodos()).isEqualTo(1); + assertThat(summary.pendingTodos()).isEqualTo(2); + assertThat(summary.userStats()).containsEntry("user1", 2L).containsEntry("user2", 1L); + } + + @Test + void should_calculate_detailed_statistics_for_date_range() { + // given + todoRepository.save( + new Todo( + null, + "Old todo", + "Outside range", + false, + "user1", + "user1", + Instant.parse("2023-12-31T23:00:00Z"), + Instant.parse("2023-12-31T23:00:00Z"))); + + todoRepository.save( + new Todo( + null, + "In range 1", + "First in range", + false, + "user1", + "user1", + Instant.parse("2024-01-01T10:00:00Z"), + Instant.parse("2024-01-01T10:00:00Z"))); + todoRepository.save( + new Todo( + null, + "In range 2", + "Completed in range", + true, + "user2", + "user2", + Instant.parse("2024-01-05T10:00:00Z"), + Instant.parse("2024-01-06T10:00:00Z"))); + + todoRepository.save( + new Todo( + null, + "After range", + "After", + false, + "user3", + "user3", + Instant.parse("2024-02-01T10:00:00Z"), + Instant.parse("2024-02-01T10:00:00Z"))); + + LocalDate from = LocalDate.parse("2024-01-01"); + LocalDate to = LocalDate.parse("2024-01-31"); + + // when + StatisticsDetailedDto detailed = statisticsService.getDetailed(from, to); + + // then + assertThat(detailed.totalTodos()).isEqualTo(2); + assertThat(detailed.completedTodos()).isEqualTo(1); + assertThat(detailed.pendingTodos()).isEqualTo(1); + assertThat(detailed.userStats()).containsEntry("user1", 1L).containsEntry("user2", 1L); + + StatisticsTodosDto todos = detailed.todos(); + assertThat(todos.completed()).hasSize(1); + assertThat(todos.pending()).hasSize(1); + } +}