Add user and project rate editing across backend and frontend
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user