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