diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java new file mode 100644 index 0000000..ed6b5b5 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingController.java @@ -0,0 +1,24 @@ +package lv.ctco.springboottemplate.features.greeting; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/greeting") +@Tag(name = "Greeting Controller", description = "Greeting related endpoints") +public class GreetingController { + private final GreetingService greetingService; + + public GreetingController(GreetingService greetingService) { + this.greetingService = greetingService; + } + + @GetMapping + @Operation(summary = "Get greeting message") + public String getGreeting() { + return greetingService.greet(); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java new file mode 100644 index 0000000..2d5770a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/greeting/GreetingService.java @@ -0,0 +1,20 @@ +package lv.ctco.springboottemplate.features.greeting; + +import lv.ctco.springboottemplate.features.todo.TodoService; +import org.springframework.stereotype.Service; + +@Service +public class GreetingService { + private final TodoService todoService; + + public GreetingService(TodoService todoService) { + this.todoService = todoService; + } + + public String greet() { + long activeTodosCount = + todoService.getAllTodos().stream().filter(todo -> !todo.completed()).count(); + + return String.format("Hello from Spring! You have %d open tasks.", activeTodosCount); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java new file mode 100644 index 0000000..a5f43a3 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsAggregationUtil.java @@ -0,0 +1,30 @@ +package lv.ctco.springboottemplate.features.statistics; + +import org.bson.Document; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class StatisticsAggregationUtil { + public static Map extractUserStats(Document root) { + if (root == null) return Map.of(); + List userStatsDocs = getArray(root, "userStats"); + Map map = new LinkedHashMap<>(); + for (Document d : userStatsDocs) { + String user = d.getString("_id"); + int count = d.getInteger("count", 0); + map.put(user, count); + } + return map; + } + + public static List getArray(Document doc, String key) { + if (doc == null) return List.of(); + Object val = doc.get(key); + if (val instanceof List list) { + return list.stream().filter(Document.class::isInstance).map(Document.class::cast).toList(); + } + return List.of(); + } +} \ No newline at end of file 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..b9886b6 --- /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.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/statistics") +@Tag(name = "Todo Statistics Controller", description = "Todo statistics related endpoints") +public class StatisticsController { + private final StatisticsService statisticsService; + + public StatisticsController(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + @GetMapping + @Operation( + summary = "Get todo statistics", + description = "Returns summary or detailed todo statistics. Optional date filters are applied to createdAt field.", + responses = { + @ApiResponse(responseCode = "200", description = "Statistics computed successfully"), + @ApiResponse(responseCode = "400", description = "Invalid parameters", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", content = @Content(schema = @Schema(implementation = StatisticsErrorResponse.class))) + } + ) + public ResponseEntity getStatistics(StatisticsRequest request) { + StatisticsResponse response = statisticsService.computeStatistics(request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java new file mode 100644 index 0000000..1e51b5d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsExceptionHandler.java @@ -0,0 +1,30 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice(assignableTypes = StatisticsController.class) +public class StatisticsExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(StatisticsExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest() + .body(new StatisticsErrorResponse(List.of(ex.getMessage()))); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntime(RuntimeException ex) { + log.error("Unhandled runtime exception in StatisticsController", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new StatisticsErrorResponse(List.of("Internal server error"))); + } +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java new file mode 100644 index 0000000..6509a54 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/StatisticsQueryBuilder.java @@ -0,0 +1,43 @@ +package lv.ctco.springboottemplate.features.statistics; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class StatisticsQueryBuilder { + public StatisticsQuery build(StatisticsRequest request) { + List errors = new ArrayList<>(); + LocalDate from = parseDate("from", request.from(), errors); + LocalDate to = parseDate("to", request.to(), errors); + + if (from != null && to != null && from.isAfter(to)) { + errors.add("Parameter 'from' must be before or equal to 'to'."); + } + + if (!errors.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", errors)); + } + + StatisticsFormat format = StatisticsQuery.parseFormat(request.format()); + return new StatisticsQuery(from, to, format); + } + + private LocalDate parseDate(String name, String raw, List errors) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return LocalDate.parse(raw); + } catch (DateTimeParseException e) { + errors.add("Invalid '" + name + "' date: " + raw); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java new file mode 100644 index 0000000..60ce42e --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsDetailedResponse.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsDetailedResponse( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats, + TodosSection todos +) implements StatisticsResponse { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java new file mode 100644 index 0000000..bb9b062 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsErrorResponse.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record StatisticsErrorResponse(List errors) { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java new file mode 100644 index 0000000..75ee135 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsFormat.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +public enum StatisticsFormat { + SUMMARY, + DETAILED +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java new file mode 100644 index 0000000..775bb33 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsQuery.java @@ -0,0 +1,28 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.LocalDate; + +public record StatisticsQuery( + LocalDate from, + LocalDate to, + StatisticsFormat format +) { + + public StatisticsQuery { + if (from != null && to != null && from.isAfter(to)) { + throw new IllegalArgumentException("Parameter 'from' must be before or equal to 'to'."); + } + } + + public static StatisticsFormat parseFormat(String value) { + if (value == null) { + return StatisticsFormat.SUMMARY; + } + + if (value.trim().equalsIgnoreCase("detailed")) { + return StatisticsFormat.DETAILED; + } + + return StatisticsFormat.SUMMARY; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java new file mode 100644 index 0000000..17c5900 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsRequest.java @@ -0,0 +1,13 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsRequest( + @Schema(description = "Start date (inclusive), format: yyyy-MM-dd", example = "2023-01-01") + String from, + @Schema(description = "End date (inclusive), format: yyyy-MM-dd", example = "2023-12-31") + String to, + @Schema(description = "Response format: summary | detailed (default: summary)", example = "summary") + String format +) { +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java new file mode 100644 index 0000000..f0d6e00 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsResponse.java @@ -0,0 +1,4 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +public interface StatisticsResponse { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java new file mode 100644 index 0000000..ff03e29 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/StatisticsSummaryResponse.java @@ -0,0 +1,11 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.Map; + +public record StatisticsSummaryResponse( + int totalTodos, + int completedTodos, + int pendingTodos, + Map userStats +) implements StatisticsResponse { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java new file mode 100644 index 0000000..18c1fa3 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosSection.java @@ -0,0 +1,6 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.util.List; + +public record TodosSection(List completed, List pending) { +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java new file mode 100644 index 0000000..a15564f --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/models/TodosStatistics.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.models; + +import java.time.Instant; +import java.util.Optional; + +public record TodosStatistics( + String id, + String title, + String createdBy, + String createdAt, + Optional completedAt) { +} \ No newline at end of file diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java new file mode 100644 index 0000000..e78e12f --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsRepository.java @@ -0,0 +1,74 @@ +package lv.ctco.springboottemplate.features.statistics.services; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.*; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.ZoneOffset; + +@Repository +public class StatisticsRepository { + + private final MongoTemplate mongoTemplate; + + public StatisticsRepository(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public Document executeSummary(StatisticsQuery query) { + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and(Aggregation.group("createdBy").count().as("count")).as("userStats") + ); + return aggregate(agg); + } + + public Document executeDetailed(StatisticsQuery query) { + ProjectionOperation projectTodosFields = Aggregation.project("title", "createdBy", "createdAt", "updatedAt", "completed", "completedAt"); + Aggregation agg = Aggregation.newAggregation( + matchDateFilter(query), + Aggregation.facet( + Aggregation.group() + .count().as("total") + .sum(ConditionalOperators.when(Criteria.where("completed").is(true)).then(1).otherwise(0)).as("completed") + ).as("counts") + .and(Aggregation.group("createdBy").count().as("count")).as("userStats") + .and(Aggregation.match(Criteria.where("completed").is(true)), projectTodosFields).as("completedTodosSource") + .and(Aggregation.match(Criteria.where("completed").is(false)), projectTodosFields).as("pendingTodosSource") + ); + return aggregate(agg); + } + + private Document aggregate(Aggregation aggregation) { + AggregationResults results = mongoTemplate.aggregate(aggregation, "todos", Document.class); + return results.getUniqueMappedResult(); + } + + private MatchOperation matchDateFilter(StatisticsQuery query) { + LocalDate from = query.from(); + LocalDate to = query.to(); + if (from == null && to == null) { + return Aggregation.match(new Criteria()); + } + Criteria criteria = Criteria.where("createdAt"); + if (from != null && to != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)) + .lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } else if (from != null) { + criteria.gte(from.atStartOfDay().toInstant(ZoneOffset.UTC)); + } else { + criteria.lte(to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).minusMillis(1)); + } + return Aggregation.match(criteria); + } +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java new file mode 100644 index 0000000..8a124ae --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/StatisticsService.java @@ -0,0 +1,37 @@ +package lv.ctco.springboottemplate.features.statistics.services; + +import lv.ctco.springboottemplate.features.statistics.StatisticsQueryBuilder; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsRequest; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.services.strategies.StatisticsComputationStrategy; +import org.springframework.stereotype.Service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +public class StatisticsService { + private final StatisticsQueryBuilder queryBuilder; + private final Map strategies = new EnumMap<>(StatisticsFormat.class); + + public StatisticsService(List strategyImplementations, StatisticsQueryBuilder queryBuilder) { + this.queryBuilder = queryBuilder; + + for (StatisticsComputationStrategy s : strategyImplementations) { + strategies.put(s.format(), s); + } + } + + public StatisticsResponse computeStatistics(StatisticsRequest request) { + StatisticsQuery query = queryBuilder.build(request); + StatisticsComputationStrategy strategy = strategies.get(query.format()); + if (strategy == null) { + throw new IllegalStateException("No strategy registered for format: " + query.format()); + } + + return strategy.compute(query); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java new file mode 100644 index 0000000..0f6779a --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/DetailedStatisticsStrategy.java @@ -0,0 +1,85 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.*; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsRepository; +import org.bson.Document; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.extractUserStats; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.getArray; + +@Component +class DetailedStatisticsStrategy implements StatisticsComputationStrategy { + + private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_INSTANT; + private final StatisticsRepository repository; + + DetailedStatisticsStrategy(StatisticsRepository repository) { + this.repository = repository; + } + + @Override + public StatisticsFormat format() { + return StatisticsFormat.DETAILED; + } + + @Override + public StatisticsResponse compute(StatisticsQuery query) { + Document root = repository.executeDetailed(query); + int total = 0; + int completed = 0; + if (root != null) { + var counts = getArray(root, "counts"); + if (!counts.isEmpty()) { + Document first = counts.getFirst(); + total = first.getInteger("total", 0); + completed = first.getInteger("completed", 0); + } + } + int pending = total - completed; + Map userStats = extractUserStats(root); + + List completedTodos = Optional.ofNullable(root) + .map(r -> getArray(r, "completedTodosSource")).orElse(List.of()).stream() + .map(d -> toTodosStatistics(d, true)) + .collect(Collectors.toList()); + + List pendingTodos = Optional.ofNullable(root) + .map(r -> getArray(r, "pendingTodosSource")).orElse(List.of()).stream() + .map(d -> toTodosStatistics(d, false)) + .collect(Collectors.toList()); + + return new StatisticsDetailedResponse( + total, + completed, + pending, + userStats, + new TodosSection(completedTodos, pendingTodos) + ); + } + + private TodosStatistics toTodosStatistics(Document d, boolean isCompleted) { + String id = d.getObjectId("_id").toHexString(); + String title = d.getString("title"); + String createdBy = d.getString("createdBy"); + Instant createdAtInstant = toInstant(d.get("createdAt")); + String createdAt = createdAtInstant == null ? null : ISO.format(createdAtInstant); + Instant completedAt = isCompleted ? Optional.ofNullable(toInstant(d.get("completedAt"))) + .or(() -> Optional.ofNullable(toInstant(d.get("updatedAt")))) + .orElse(null) : null; + return new TodosStatistics(id, title, createdBy, createdAt, Optional.ofNullable(completedAt)); + } + + private Instant toInstant(Object obj) { + if (obj instanceof java.util.Date date) return date.toInstant(); + if (obj instanceof Instant inst) return inst; + return null; + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java new file mode 100644 index 0000000..495942d --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/StatisticsComputationStrategy.java @@ -0,0 +1,12 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; + +public interface StatisticsComputationStrategy { + StatisticsFormat format(); + + StatisticsResponse compute(StatisticsQuery query); +} + diff --git a/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java new file mode 100644 index 0000000..583d904 --- /dev/null +++ b/src/main/java/lv/ctco/springboottemplate/features/statistics/services/strategies/SummaryStatisticsStrategy.java @@ -0,0 +1,48 @@ +package lv.ctco.springboottemplate.features.statistics.services.strategies; + +import lv.ctco.springboottemplate.features.statistics.models.StatisticsFormat; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsQuery; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsResponse; +import lv.ctco.springboottemplate.features.statistics.models.StatisticsSummaryResponse; +import lv.ctco.springboottemplate.features.statistics.services.StatisticsRepository; +import org.bson.Document; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.extractUserStats; +import static lv.ctco.springboottemplate.features.statistics.StatisticsAggregationUtil.getArray; + +@Component +class SummaryStatisticsStrategy implements StatisticsComputationStrategy { + + private final StatisticsRepository repository; + + SummaryStatisticsStrategy(StatisticsRepository repository) { + this.repository = repository; + } + + @Override + public StatisticsFormat format() { + return StatisticsFormat.SUMMARY; + } + + @Override + public StatisticsResponse compute(StatisticsQuery query) { + Document root = repository.executeSummary(query); + int total = 0; + int completed = 0; + if (root != null) { + List counts = getArray(root, "counts"); + if (!counts.isEmpty()) { + Document first = counts.getFirst(); + total = first.getInteger("total", 0); + completed = first.getInteger("completed", 0); + } + } + int pending = total - completed; + Map userStats = extractUserStats(root); + return new StatisticsSummaryResponse(total, completed, pending, userStats); + } +} diff --git a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java index 2b24a9a..fe8d438 100644 --- a/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java +++ b/src/main/java/lv/ctco/springboottemplate/features/todo/TodoDataInitializer.java @@ -1,13 +1,15 @@ package lv.ctco.springboottemplate.features.todo; -import java.time.Instant; -import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + @Configuration public class TodoDataInitializer { @@ -58,7 +60,26 @@ CommandLineRunner initDatabase(TodoRepository todoRepository) { "system", "system", now, - now)); + now), + new Todo( + null, + "Test Todo", + "Research destinations", + true, + "user1", + "user1", + LocalDate.of(2019, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC), + LocalDate.of(2023, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC)), + new Todo( + null, + "Test Todo2", + "Research destinations", + false, + "user2", + "user2", + LocalDate.of(2023, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC), + LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(java.time.ZoneOffset.UTC)) + ); todoRepository.saveAll(todos); log.info("Initialized database with {} todo items", todos.size()); diff --git a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java index eda5f13..ddcc817 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/greeting/GreetingServiceIntegrationTest.java @@ -1,32 +1,34 @@ package lv.ctco.springboottemplate.features.greeting; +import lv.ctco.springboottemplate.features.todo.Todo; +import lv.ctco.springboottemplate.features.todo.TodoRepository; import lv.ctco.springboottemplate.features.todo.TodoService; -import org.junit.jupiter.api.Disabled; +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.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; /** - * Integration test for {@link GreetingService}. + * Integration test for {@link GreetingService} without Testcontainers. * - *

This test verifies that GreetingService correctly interacts with {@link TodoService} and - * reflects the number of open (not completed) todos in the message. - * - *

Initially marked {@link Disabled} to be enabled by the developer after implementation. + *

This test uses a locally running MongoDB instance (expected at + * mongodb://localhost:27017/tododb). If you prefer an embedded/in-memory Mongo, add flapdoodle + * dependency and remove the dynamic property below. */ @SpringBootTest -@Disabled("Enable after implementing GreetingService using TodoService") -@Testcontainers @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class GreetingServiceIntegrationTest { - /* - - @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); - + // Point Spring Data Mongo to a locally running MongoDB instead of using Testcontainers @DynamicPropertySource - static void setProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + static void mongoProps(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", () -> "mongodb://localhost:27017/tododb"); } private final TodoService todoService; @@ -74,10 +76,10 @@ void should_return_zero_open_tasks_message_if_none_exist() { @Test void should_work_with_no_todos_at_all() { // when - String message = greetingService.greet(); + String msg = greetingService.greet(); // then - assertThat(message).contains("Hello").contains("0 open tasks"); + assertThat(msg).contains("Hello").contains("0 open tasks"); } @Test @@ -94,5 +96,4 @@ void should_ignore_null_todos_or_null_completed_flags() { // then assertThat(message).contains("1 open task"); } - */ } 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..27436db 100644 --- a/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java +++ b/src/test/java/lv/ctco/springboottemplate/features/todo/TodoServiceIntegrationTest.java @@ -1,39 +1,30 @@ package lv.ctco.springboottemplate.features.todo; -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; 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.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; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest -@Testcontainers -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) class TodoServiceIntegrationTest { - @Container static MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:6.0.8"); - @DynamicPropertySource static void setProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl); + registry.add("spring.data.mongodb.uri", () -> "mongodb://localhost:27017/tododb"); } + @Autowired private TodoRepository todoRepository; + @Autowired private TodoService todoService; - public TodoServiceIntegrationTest(TodoRepository todoRepository, TodoService todoService) { - this.todoRepository = todoRepository; - this.todoService = todoService; - } - @BeforeEach void setup() { todoRepository.deleteAll(); @@ -48,12 +39,12 @@ void shouldCreateAndRetrieveTodo() { String createdBy = "test-user"; // when - Todo created = todoService.createTodo(title, description, completed, createdBy); - List allTodos = todoService.getAllTodos(); + todoService.createTodo(title, description, completed, createdBy); + List todos = todoService.getAllTodos(); // then - assertThat(allTodos).hasSize(1); - Todo todo = allTodos.get(0); + assertThat(todos).hasSize(1); + Todo todo = todos.getFirst(); assertThat(todo.id()).isNotNull(); assertThat(todo.title()).isEqualTo(title); @@ -110,6 +101,6 @@ void shouldSearchByTitle() { // then assertThat(result).hasSize(1); - assertThat(result.get(0).title()).containsIgnoringCase("milk"); + assertThat(result.getFirst().title()).containsIgnoringCase("milk"); } }