Add user and project rate editing across backend and frontend

This commit is contained in:
2026-05-21 13:43:17 +02:00
parent 38f1461d37
commit 1b420271c7
13 changed files with 623 additions and 40 deletions
@@ -2,6 +2,7 @@ package com.example.app.project;
import com.example.app.project.ProjectDtos.CreateProjectRequest;
import com.example.app.project.ProjectDtos.ProjectResponse;
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
@@ -27,4 +28,10 @@ public class ProjectController {
public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
return projectService.create(req);
}
@PutMapping("/{id}")
public ProjectResponse update(
@PathVariable Long id, @Valid @RequestBody UpdateProjectRequest req) {
return projectService.update(id, req);
}
}
@@ -12,6 +12,12 @@ public class ProjectDtos {
@PositiveOrZero int costBudgetCents,
@PositiveOrZero Integer hourlyRateCents) {}
public record UpdateProjectRequest(
@NotBlank String name,
@Min(1) int timeBudgetMinutes,
@PositiveOrZero int costBudgetCents,
@PositiveOrZero Integer hourlyRateCents) {}
public record ProjectResponse(
Long id,
String name,
@@ -2,7 +2,9 @@ package com.example.app.project;
import com.example.app.project.ProjectDtos.CreateProjectRequest;
import com.example.app.project.ProjectDtos.ProjectResponse;
import com.example.app.project.ProjectDtos.UpdateProjectRequest;
import com.example.app.time.TimeEntryRepository;
import jakarta.persistence.EntityNotFoundException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -53,4 +55,29 @@ public class ProjectService {
Project saved = projectRepository.save(p);
return ProjectResponse.from(saved, 0, 0);
}
@Transactional
public ProjectResponse update(Long id, UpdateProjectRequest req) {
Project project =
projectRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Project not found: " + id));
project.setName(req.name());
project.setTimeBudgetMinutes(req.timeBudgetMinutes());
project.setCostBudgetCents(req.costBudgetCents());
project.setHourlyRateCents(req.hourlyRateCents());
Project saved = projectRepository.save(project);
long[] totals = new long[] {0, 0};
for (Object[] row : timeEntryRepository.aggregateAllByProject()) {
long projectId = ((Number) row[0]).longValue();
if (projectId == saved.getId()) {
totals[0] = ((Number) row[1]).longValue();
totals[1] = ((Number) row[2]).longValue();
break;
}
}
return ProjectResponse.from(saved, totals[0], totals[1]);
}
}
@@ -1,8 +1,13 @@
package com.example.app.user;
import com.example.app.user.UserDtos.UpdateUserRequest;
import com.example.app.user.UserDtos.UserResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -20,4 +25,9 @@ public class UserController {
public List<UserResponse> list() {
return userService.findAll();
}
@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest req) {
return userService.update(id, req);
}
}
@@ -1,7 +1,13 @@
package com.example.app.user;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
public class UserDtos {
public record UpdateUserRequest(
@NotBlank String name, @PositiveOrZero int defaultHourlyRateCents) {}
public record UserResponse(Long id, String name, int defaultHourlyRateCents) {
public static UserResponse from(User user) {
@@ -1,6 +1,8 @@
package com.example.app.user;
import com.example.app.user.UserDtos.UpdateUserRequest;
import com.example.app.user.UserDtos.UserResponse;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -18,4 +20,16 @@ public class UserService {
public List<UserResponse> findAll() {
return userRepository.findAll().stream().map(UserResponse::from).toList();
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest req) {
User user =
userRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
user.setName(req.name());
user.setDefaultHourlyRateCents(req.defaultHourlyRateCents());
User saved = userRepository.save(user);
return UserResponse.from(saved);
}
}
@@ -0,0 +1,123 @@
package com.example.app;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
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.Project;
import com.example.app.project.ProjectRepository;
import com.example.app.user.User;
import com.example.app.user.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
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 RateUpdateEndpointsTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@Autowired UserRepository userRepository;
@Autowired ProjectRepository projectRepository;
@Test
void putUser_updatesNameAndDefaultRate() throws Exception {
User user = userRepository.findAll().get(0);
String originalName = user.getName();
int originalRate = user.getDefaultHourlyRateCents();
try {
String body =
objectMapper.writeValueAsString(
Map.of("name", "Updated User", "defaultHourlyRateCents", 12345));
mockMvc
.perform(
put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated User"))
.andExpect(jsonPath("$.defaultHourlyRateCents").value(12345));
} finally {
user.setName(originalName);
user.setDefaultHourlyRateCents(originalRate);
userRepository.save(user);
}
}
@Test
void putUser_rejectsNegativeRate() throws Exception {
User user = userRepository.findAll().get(0);
String body =
objectMapper.writeValueAsString(Map.of("name", "X", "defaultHourlyRateCents", -1));
mockMvc
.perform(
put("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void putProject_updatesFieldsIncludingRate() throws Exception {
Project project = projectRepository.findAll().get(0);
String originalName = project.getName();
int originalTime = project.getTimeBudgetMinutes();
int originalCost = project.getCostBudgetCents();
Integer originalRate = project.getHourlyRateCents();
try {
String body =
objectMapper.writeValueAsString(
Map.of(
"name", "Updated Project",
"timeBudgetMinutes", 600,
"costBudgetCents", 99999,
"hourlyRateCents", 15000));
mockMvc
.perform(
put("/api/projects/{id}", project.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated Project"))
.andExpect(jsonPath("$.timeBudgetMinutes").value(600))
.andExpect(jsonPath("$.costBudgetCents").value(99999))
.andExpect(jsonPath("$.hourlyRateCents").value(15000));
} finally {
project.setName(originalName);
project.setTimeBudgetMinutes(originalTime);
project.setCostBudgetCents(originalCost);
project.setHourlyRateCents(originalRate);
projectRepository.save(project);
}
}
@Test
void putProject_rejectsBlankName() throws Exception {
Project project = projectRepository.findAll().get(0);
String body =
objectMapper.writeValueAsString(
Map.of(
"name", "",
"timeBudgetMinutes", 600,
"costBudgetCents", 99999,
"hourlyRateCents", 15000));
mockMvc
.perform(
put("/api/projects/{id}", project.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}
@@ -45,6 +45,46 @@ class TimeEntryRepositoryTest {
timeEntryRepository.deleteAll(List.of(inRange, outOfRange));
}
@Test
void aggregateByDay_reflectsUpdatedRatesRetroactively() {
User user = new User();
user.setName("Rate Test User");
user.setDefaultHourlyRateCents(6000);
user = userRepository.save(user);
Project project = new Project();
project.setName("Rate Test Project");
project.setTimeBudgetMinutes(600);
project.setCostBudgetCents(100_000);
project.setHourlyRateCents(null);
project = projectRepository.save(project);
LocalDate day = LocalDate.of(2026, 5, 20);
TimeEntry entry = entry(user, project, day, 60);
entry = timeEntryRepository.save(entry);
try {
List<Object[]> initial = timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(initial).hasSize(1);
assertThat(((Number) initial.get(0)[2]).longValue()).isEqualTo(6000);
user.setDefaultHourlyRateCents(9000);
userRepository.save(user);
List<Object[]> updatedUserRate = timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(((Number) updatedUserRate.get(0)[2]).longValue()).isEqualTo(9000);
project.setHourlyRateCents(12000);
projectRepository.save(project);
List<Object[]> updatedProjectRate =
timeEntryRepository.aggregateByDay(user.getId(), day, day);
assertThat(((Number) updatedProjectRate.get(0)[2]).longValue()).isEqualTo(12000);
} finally {
timeEntryRepository.delete(entry);
projectRepository.delete(project);
userRepository.delete(user);
}
}
private TimeEntry entry(User user, Project project, LocalDate date, int minutes) {
TimeEntry e = new TimeEntry();
e.setUser(user);