Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: Java CI with Gradle

on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ Open Swagger docs: http://localhost:8080/swagger-ui.html
3. **Pull Request Process**
- Create a PR from your task branch to your personal feature branch
- Assign the following reviewers to your PR:
- https://github.com/trioletas
- https://github.com/algorithm108
- https://github.com/IgorGursky
- https://github.com/trioletas (Oleg)
- https://github.com/algorithm108 (Alexey)
- https://github.com/IgorGursky (Igor)
- https://github.com/Melancholic (Andrey)

### 📝 Available Tasks

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11")
compileOnly("org.projectlombok:lombok:1.18.38")
annotationProcessor("org.projectlombok:lombok:1.18.38")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package lv.ctco.springboottemplate.features.statistics;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum;
import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/statistics")
@Tag(name = "Statistics Controller", description = "Statistics endpoints")
public class StatisticsController {

private final StatisticsService statisticsService;

@GetMapping
@Operation(summary = "Query aggregated statistics results")
public TodoStatsResponseDTO query(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
Optional<LocalDate> from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
Optional<LocalDate> to,
@RequestParam(defaultValue = "SUMMARY") StatsFormatEnum format) {
return statisticsService.query(from, to, format);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package lv.ctco.springboottemplate.features.statistics;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum;
import lv.ctco.springboottemplate.features.statistics.dto.TodoBreakdownDTO;
import lv.ctco.springboottemplate.features.statistics.dto.TodoItemDTO;
import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO;
import lv.ctco.springboottemplate.features.todo.Todo;
import lv.ctco.springboottemplate.features.todo.TodoRepository;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class StatisticsService {

private final MongoTemplate mongoTemplate;
private final TodoRepository todoRepository;

public TodoStatsResponseDTO query(
Optional<LocalDate> from, Optional<LocalDate> to, StatsFormatEnum format) {
Criteria criteria = Criteria.where("createdAt");
;

if (from.isPresent()) criteria = criteria.gte(from.get());
if (to.isPresent()) criteria = criteria.lte(to.get());

Query query = new Query(criteria);

List<Todo> todos = mongoTemplate.find(query, Todo.class);

return buildSummaryFromTodos(todos, format);
}

private TodoStatsResponseDTO buildSummaryFromTodos(List<Todo> todos, StatsFormatEnum format) {
int total = todos.size();
int completed = (int) todos.stream().filter(Todo::completed).count();
int pending = total - completed;

List<TodoItemDTO> completedList =
todos.stream().filter(Todo::completed).map(this::toDto).toList();

List<TodoItemDTO> pendingList =
todos.stream().filter(t -> !t.completed()).map(this::toDto).toList();

Map<String, Integer> userStats =
todos.stream()
.collect(Collectors.groupingBy(Todo::createdBy, Collectors.summingInt(t -> 1)));

TodoBreakdownDTO breakdown = new TodoBreakdownDTO(completedList, pendingList);
return new TodoStatsResponseDTO(
total,
completed,
pending,
userStats,
format == StatsFormatEnum.DETAILED ? Optional.of(breakdown) : Optional.empty());
}

private TodoItemDTO toDto(Todo todo) {
return new TodoItemDTO(
todo.id(),
todo.title(),
todo.createdBy(),
todo.createdAt(),
todo.completed() ? todo.updatedAt() : null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package lv.ctco.springboottemplate.features.statistics.dto;

public enum StatsFormatEnum {
SUMMARY,
DETAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import java.util.List;

public record TodoBreakdownDTO(List<TodoItemDTO> completed, List<TodoItemDTO> pending) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TodoItemDTO(
String id, String title, String createdBy, Instant createdAt, Instant completedAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package lv.ctco.springboottemplate.features.statistics.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Map;
import java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TodoStatsResponseDTO(
int totalTodos,
int completedTodos,
int pendingTodos,
Map<String, Integer> userStats,
Optional<TodoBreakdownDTO> todos) {}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,28 @@ public List<Todo> searchTodos(String title) {
}

public Todo createTodo(String title, String description, boolean completed, String createdBy) {
return createTodo(title, description, completed, createdBy, null, null);
}

public Todo createTodo(
String title,
String description,
boolean completed,
String createdBy,
Instant createdAt,
Instant updatedAt) {

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,
createdAt != null ? createdAt : now,
updatedAt != null ? updatedAt : now);
return todoRepository.save(todo);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
Expand All @@ -14,7 +15,10 @@ public class FeatureNamingConventionTest {

@BeforeAll
static void setup() {
importedClasses = new ClassFileImporter().importPackages("lv.ctco.springboottemplate");
importedClasses =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("lv.ctco.springboottemplate.features");
}

@Test
Expand All @@ -25,6 +29,10 @@ void only_validly_named_top_level_classes_should_exist_in_features_package() {
.resideInAPackage("..features..")
.and()
.haveNameNotMatching(".*\\$.*") // ← exclude inner classes
.and()
.areNotRecords()
.and()
.areNotEnums()
.should()
.haveSimpleNameEndingWith("Controller")
.orShould()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ void non_beans_should_not_have_unused_non_private_static_methods() {
.areNotAnnotatedWith(Repository.class)
.and()
.areNotAnnotatedWith(RestController.class)
.and()
.areNotEnums() // Exclude enums from the rule
.should(
new ArchCondition<>("have all non-private static methods used") {
@Override
Expand All @@ -50,6 +52,16 @@ public void check(JavaClass item, ConditionEvents events) {
.filter(method -> method.getModifiers().contains(JavaModifier.STATIC))
.filter(method -> !method.getModifiers().contains(JavaModifier.PRIVATE))
.filter(method -> method.getAccessesToSelf().isEmpty())
.filter(
method ->
!(item.isEnum()
&& method.getOwner().isEnum()
&& (method.getName().equals("values")
|| method
.getName()
.equals(
"valueOf")))) // Ensure exclusion of enum-generated
// methods
.forEach(
method ->
events.add(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package lv.ctco.springboottemplate.features.statistics;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lv.ctco.springboottemplate.common.MongoDbContainerTestSupport;
import lv.ctco.springboottemplate.features.statistics.dto.StatsFormatEnum;
import lv.ctco.springboottemplate.features.statistics.dto.TodoStatsResponseDTO;
import lv.ctco.springboottemplate.features.todo.TodoRepository;
import lv.ctco.springboottemplate.features.todo.TodoService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestConstructor;
import org.testcontainers.junit.jupiter.Testcontainers;

@RequiredArgsConstructor
@SpringBootTest
@Testcontainers
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class StatisticsServiceIntegrationTest extends MongoDbContainerTestSupport {

private final StatisticsService statisticsService;
private final TodoRepository todoRepository;
private final TodoService todoService;

@BeforeEach
void clean() {
todoRepository.deleteAll();
}

@Test
void summary_should_return_counts_and_userStats_without_breakdown() {
// given
todoService.createTodo("Buy bolt pistols", "For the squad", false, "marine");
todoService.createTodo("Bless the lasgun", "With machine oil", true, "techpriest");
todoService.createTodo("Charge plasma cell", "Don't overheat!", false, "marine");

// when
TodoStatsResponseDTO dto =
statisticsService.query(
Optional.of(LocalDate.of(2025, 9, 11)), Optional.empty(), StatsFormatEnum.SUMMARY);

// then
assertThat(dto.totalTodos()).isEqualTo(3);
assertThat(dto.completedTodos()).isEqualTo(1);
assertThat(dto.pendingTodos()).isEqualTo(2);

Map<String, Integer> userStats = dto.userStats();
assertThat(userStats).containsEntry("marine", 2);
assertThat(userStats).containsEntry("techpriest", 1);

assertThat(dto.todos()).isEmpty();
}

@Test
void detailed_should_include_breakdown_with_correct_lists() {
// given
todoService.createTodo("A", "d", true, "alpha");
todoService.createTodo("B", "d", false, "beta");
todoService.createTodo("C", "d", false, "alpha");

// when
TodoStatsResponseDTO dto =
statisticsService.query(
Optional.of(LocalDate.of(2025, 9, 11)), Optional.empty(), StatsFormatEnum.DETAILED);

// then
assertThat(dto.totalTodos()).isEqualTo(3);
assertThat(dto.completedTodos()).isEqualTo(1);
assertThat(dto.pendingTodos()).isEqualTo(2);

assertThat(dto.todos()).isPresent();
var breakdown = dto.todos().orElseThrow();

assertThat(breakdown.completed()).hasSize(1);
assertThat(breakdown.pending()).hasSize(2);

assertThat(breakdown.completed().getFirst().completedAt()).isNotNull();
assertThat(breakdown.pending()).allSatisfy(item -> assertThat(item.completedAt()).isNull());
}

@Test
void query_should_filter_by_inclusive_date_range() {
// given
LocalDate d10 = LocalDate.of(2025, 9, 10);
LocalDate d11 = LocalDate.of(2025, 9, 11);
LocalDate d12 = LocalDate.of(2025, 9, 12);

Instant d1 = Instant.parse("2025-09-10T09:00:00Z");
Instant d2 = Instant.parse("2025-09-11T09:00:00Z");
Instant d3 = Instant.parse("2025-09-12T09:00:00Z");

todoService.createTodo("D10-1", "x", false, "u1", d1, d1);
todoService.createTodo("D11-1", "x", true, "u1", d2, d2);
todoService.createTodo("D12-1", "x", false, "u2", d3, d3);

// when
TodoStatsResponseDTO dto =
statisticsService.query(Optional.of(d11), Optional.of(d12), StatsFormatEnum.SUMMARY);

// then
assertThat(dto.totalTodos()).isGreaterThanOrEqualTo(1); // depends on Instant.now()
assertThat(dto.todos()).isEmpty();
}

@Test
void should_handle_empty_dataset() {
// when
TodoStatsResponseDTO dto =
statisticsService.query(Optional.empty(), Optional.empty(), StatsFormatEnum.SUMMARY);

// then
assertThat(dto.totalTodos()).isZero();
assertThat(dto.completedTodos()).isZero();
assertThat(dto.pendingTodos()).isZero();
assertThat(dto.userStats()).isEmpty();
assertThat(dto.todos()).isEmpty();
}
}