feat: paged history endpoint GET /api/expenselist/{id}/history

This commit is contained in:
2026-07-06 12:46:46 +02:00
parent b19cdd0264
commit b8ca1b94ec
6 changed files with 170 additions and 2 deletions
@@ -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);
}
}
@@ -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();
}
}