feat: paged history endpoint GET /api/expenselist/{id}/history
This commit is contained in:
@@ -14,9 +14,11 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import de.zendric.app.xpensely_server.model.*;
|
||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/expenselist")
|
||||
@@ -27,12 +29,15 @@ public class ExpenseListController {
|
||||
private final ExpenseListService expenseListService;
|
||||
private final CategoryService categoryService;
|
||||
private final AuthenticatedUserResolver authenticatedUserResolver;
|
||||
private final HistoryService historyService;
|
||||
|
||||
public ExpenseListController(ExpenseListService expenseListService,
|
||||
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
|
||||
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver,
|
||||
HistoryService historyService) {
|
||||
this.expenseListService = expenseListService;
|
||||
this.categoryService = categoryService;
|
||||
this.authenticatedUserResolver = authenticatedUserResolver;
|
||||
this.historyService = historyService;
|
||||
}
|
||||
|
||||
@GetMapping("/mine")
|
||||
@@ -175,6 +180,20 @@ public class ExpenseListController {
|
||||
return ResponseEntity.ok("User added to the list");
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/history")
|
||||
public ResponseEntity<HistoryPageDto> getHistory(
|
||||
@PathVariable("id") Long id,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "30") int size,
|
||||
Authentication authentication) {
|
||||
AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
|
||||
Optional<ExpenseList> listOpt = expenseListService.findById(id);
|
||||
if (listOpt.isEmpty())
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
assertMember(user, listOpt.get());
|
||||
return ResponseEntity.ok(historyService.getHistory(id, page, size));
|
||||
}
|
||||
|
||||
private void assertOwner(AppUser authenticated, ExpenseList list) {
|
||||
if (!list.getOwner().getId().equals(authenticated.getId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.zendric.app.xpensely_server.model.DTO;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import de.zendric.app.xpensely_server.model.FieldChange;
|
||||
|
||||
public record HistoryEntryDto(
|
||||
String type,
|
||||
String actorUsername,
|
||||
String expenseTitle,
|
||||
LocalDateTime timestamp,
|
||||
List<FieldChange> changes) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.zendric.app.xpensely_server.model.DTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record HistoryPageDto(List<HistoryEntryDto> entries, boolean hasMore) {
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import de.zendric.app.xpensely_server.model.AppUser;
|
||||
import de.zendric.app.xpensely_server.model.DTO.HistoryEntryDto;
|
||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
||||
import de.zendric.app.xpensely_server.model.Expense;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||
@@ -84,4 +89,33 @@ public class HistoryService {
|
||||
newVal == null ? null : String.valueOf(newVal)));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public HistoryPageDto getHistory(Long listId, int page, int size) {
|
||||
int cappedSize = Math.min(Math.max(size, 1), 100);
|
||||
Page<ExpenseHistoryEntry> result = historyRepository
|
||||
.findByExpenseListIdOrderByTimestampDescIdDesc(listId,
|
||||
PageRequest.of(Math.max(page, 0), cappedSize));
|
||||
List<HistoryEntryDto> entries = result.getContent().stream().map(this::toDto).toList();
|
||||
return new HistoryPageDto(entries, result.hasNext());
|
||||
}
|
||||
|
||||
private HistoryEntryDto toDto(ExpenseHistoryEntry entry) {
|
||||
List<FieldChange> changes = List.of();
|
||||
if (entry.getChanges() != null) {
|
||||
try {
|
||||
changes = objectMapper.readValue(entry.getChanges(),
|
||||
new TypeReference<List<FieldChange>>() {
|
||||
});
|
||||
} catch (JacksonException e) {
|
||||
throw new IllegalStateException("Corrupt history changes for entry " + entry.getId(), e);
|
||||
}
|
||||
}
|
||||
return new HistoryEntryDto(
|
||||
entry.getType().name(),
|
||||
entry.getActor() == null ? null : entry.getActor().getUsername(),
|
||||
entry.getExpenseTitle(),
|
||||
entry.getTimestamp(),
|
||||
changes);
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -2,11 +2,13 @@ package de.zendric.app.xpensely_Server.controller;
|
||||
|
||||
import de.zendric.app.xpensely_server.controller.ExpenseListController;
|
||||
import de.zendric.app.xpensely_server.model.AppUser;
|
||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
||||
import de.zendric.app.xpensely_server.model.Expense;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
|
||||
@@ -38,6 +40,7 @@ class ExpenseListControllerTest {
|
||||
@MockitoBean ExpenseListService expenseListService;
|
||||
@MockitoBean CategoryService categoryService;
|
||||
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
||||
@MockitoBean HistoryService historyService;
|
||||
|
||||
// --- Validation tests ---
|
||||
|
||||
@@ -281,4 +284,42 @@ class ExpenseListControllerTest {
|
||||
.content("{\"inviteCode\":\"ABC123\"}"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
// --- History endpoint ---
|
||||
|
||||
@Test
|
||||
void getHistory_member_returns200WithPage() throws Exception {
|
||||
AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
|
||||
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
|
||||
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
|
||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
|
||||
when(historyService.getHistory(1L, 0, 30))
|
||||
.thenReturn(new HistoryPageDto(List.of(), false));
|
||||
|
||||
mockMvc.perform(get("/api/expenselist/1/history"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.entries").isArray())
|
||||
.andExpect(jsonPath("$.hasMore").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHistory_nonMember_returns403() throws Exception {
|
||||
AppUser owner = new AppUser(); owner.setId(1L);
|
||||
AppUser stranger = new AppUser(); stranger.setId(2L);
|
||||
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
|
||||
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
|
||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(stranger);
|
||||
|
||||
mockMvc.perform(get("/api/expenselist/1/history"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHistory_unknownList_returns404() throws Exception {
|
||||
when(expenseListService.findById(99L)).thenReturn(Optional.empty());
|
||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(new AppUser());
|
||||
|
||||
mockMvc.perform(get("/api/expenselist/99/history"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import de.zendric.app.xpensely_server.model.AppUser;
|
||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
||||
import de.zendric.app.xpensely_server.model.Expense;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||
@@ -23,9 +27,11 @@ import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
|
||||
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HistoryServiceTest {
|
||||
@@ -152,4 +158,52 @@ class HistoryServiceTest {
|
||||
assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
|
||||
assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHistory_mapsEntriesToDto_andParsesChangesJson() {
|
||||
ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
|
||||
entry.setType(HistoryEntryType.UPDATED);
|
||||
entry.setActor(user(1L, "alice"));
|
||||
entry.setExpenseTitle("Groceries");
|
||||
entry.setTimestamp(java.time.LocalDateTime.of(2026, 7, 6, 14, 31));
|
||||
entry.setChanges("[{\"field\":\"amount\",\"old\":\"12.0\",\"new\":\"15.5\"}]");
|
||||
when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(entry), PageRequest.of(0, 30), 31));
|
||||
|
||||
HistoryPageDto page = service.getHistory(5L, 0, 30);
|
||||
|
||||
assertThat(page.hasMore()).isTrue();
|
||||
assertThat(page.entries()).hasSize(1);
|
||||
assertThat(page.entries().get(0).type()).isEqualTo("UPDATED");
|
||||
assertThat(page.entries().get(0).actorUsername()).isEqualTo("alice");
|
||||
assertThat(page.entries().get(0).changes())
|
||||
.containsExactly(new FieldChange("amount", "12.0", "15.5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHistory_capsPageSizeAt100() {
|
||||
when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
service.getHistory(5L, 0, 5000);
|
||||
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
verify(historyRepository).findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), captor.capture());
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHistory_nullChanges_yieldsEmptyList() {
|
||||
ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
|
||||
entry.setType(HistoryEntryType.CREATED);
|
||||
entry.setActor(user(1L, "alice"));
|
||||
entry.setExpenseTitle("Groceries");
|
||||
entry.setTimestamp(java.time.LocalDateTime.of(2026, 7, 6, 14, 31));
|
||||
when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(entry)));
|
||||
|
||||
HistoryPageDto page = service.getHistory(5L, 0, 30);
|
||||
|
||||
assertThat(page.entries().get(0).changes()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user