test(backend): add happy-path test pyramid — repo, service, e2e

- TimeEntryRepositoryTest (@DataJpaTest): inserts entries across two weeks,
  asserts date-range query returns only the correct slice
- ReportServiceTest (@ExtendWith(MockitoExtension)): mocks repository,
  covers weekly aggregation, parseIsoWeekToMonday returning a Monday,
  and monthly breach detection (both time and cost thresholds)
- TimeEntryEndToEndTest (@SpringBootTest + MockMvc): POST a 90-min entry,
  GET /api/reports/weekly, assert weekTotalMinutes=90

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 11:42:42 +02:00
parent ad23616c7b
commit 6029085d1c
3 changed files with 188 additions and 0 deletions
@@ -0,0 +1,63 @@
package com.example.app.report;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import com.example.app.report.ReportDtos.WeeklyReport;
import com.example.app.time.TimeEntryRepository;
import java.sql.Date;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock TimeEntryRepository timeEntryRepository;
@InjectMocks ReportService reportService;
@Test
void weeklyReport_aggregatesDayTotalsCorrectly() {
LocalDate monday = ReportService.parseIsoWeekToMonday("2026-W20");
Object[] row = {Date.valueOf(monday), 120L, 9600L}; // 2h, 96.00 EUR
when(timeEntryRepository.aggregateByDay(anyLong(), any(), any())).thenReturn(List.of(row));
WeeklyReport report = reportService.weeklyReport(1L, "2026-W20");
assertThat(report.days()).hasSize(7);
assertThat(report.weekTotalMinutes()).isEqualTo(120);
assertThat(report.weekTotalCostCents()).isEqualTo(9600);
// first day has data, rest are zero
assertThat(report.days().get(0).totalMinutes()).isEqualTo(120);
assertThat(report.days().get(1).totalMinutes()).isEqualTo(0);
}
@Test
void parseIsoWeekToMonday_returnsCorrectMonday() {
// 2026-W01 should start on Monday 2025-12-29 (ISO week based year)
LocalDate monday = ReportService.parseIsoWeekToMonday("2026-W20");
assertThat(monday.getDayOfWeek().getValue()).isEqualTo(1); // Monday
}
@Test
void monthlyReport_marksBreachedProjects() {
// budget 60 minutes, logged 90 — breached
Object[] row = {1L, "Acme", 60, 100_00, 90L, 120_00L};
when(timeEntryRepository.aggregateByProjectForMonth(anyLong(), anyInt(), anyInt()))
.thenReturn(List.of(row));
var report = reportService.monthlyReport(1L, "2026-05");
assertThat(report.projects()).hasSize(1);
assertThat(report.projects().get(0).breached()).isTrue();
assertThat(report.projects().get(0).timeBudgetPct()).isGreaterThan(100.0);
}
}
@@ -0,0 +1,69 @@
package com.example.app.time;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.example.app.project.ProjectRepository;
import com.example.app.user.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
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.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class TimeEntryEndToEndTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@Autowired TimeEntryRepository timeEntryRepository;
@Autowired UserRepository userRepository;
@Autowired ProjectRepository projectRepository;
@AfterEach
void cleanup() {
timeEntryRepository.deleteAll();
}
@Test
void postTimeEntry_thenAppearInWeeklyReport() throws Exception {
long userId = userRepository.findAll().get(0).getId();
long projectId = projectRepository.findAll().get(0).getId();
LocalDate today = LocalDate.now();
String isoWeek = today.format(DateTimeFormatter.ofPattern("YYYY-'W'ww"));
String body =
objectMapper.writeValueAsString(
Map.of(
"userId",
userId,
"projectId",
projectId,
"entryDate",
today.toString(),
"durationMinutes",
90));
mockMvc
.perform(post("/api/time-entries").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.durationMinutes").value(90));
mockMvc
.perform(
get("/api/reports/weekly")
.param("userId", String.valueOf(userId))
.param("week", isoWeek))
.andExpect(status().isOk())
.andExpect(jsonPath("$.weekTotalMinutes").value(90));
}
}
@@ -0,0 +1,56 @@
package com.example.app.time;
import static org.assertj.core.api.Assertions.assertThat;
import com.example.app.project.Project;
import com.example.app.project.ProjectRepository;
import com.example.app.user.User;
import com.example.app.user.UserRepository;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(
properties = {"spring.jpa.hibernate.ddl-auto=validate", "spring.flyway.enabled=true"})
class TimeEntryRepositoryTest {
@Autowired TimeEntryRepository timeEntryRepository;
@Autowired UserRepository userRepository;
@Autowired ProjectRepository projectRepository;
@Test
void findByUserAndDateRange_returnsOnlyEntriesInRange() {
User user = userRepository.findAll().get(0);
Project project = projectRepository.findAll().get(0);
LocalDate today = LocalDate.now();
TimeEntry inRange = entry(user, project, today, 60);
TimeEntry outOfRange = entry(user, project, today.minusDays(10), 30);
timeEntryRepository.saveAll(List.of(inRange, outOfRange));
List<TimeEntry> result =
timeEntryRepository.findByUserAndDateRange(
user.getId(), today.minusDays(1), today.plusDays(1));
assertThat(result).hasSize(1);
assertThat(result.get(0).getDurationMinutes()).isEqualTo(60);
// cleanup
timeEntryRepository.deleteAll(List.of(inRange, outOfRange));
}
private TimeEntry entry(User user, Project project, LocalDate date, int minutes) {
TimeEntry e = new TimeEntry();
e.setUser(user);
e.setProject(project);
e.setEntryDate(date);
e.setDurationMinutes(minutes);
return e;
}
}