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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user