From b8ca1b94ec798de4f219de107213a6a6598a81be Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:46:46 +0200 Subject: [PATCH] feat: paged history endpoint GET /api/expenselist/{id}/history --- .../controller/ExpenseListController.java | 21 ++++++- .../model/DTO/HistoryEntryDto.java | 14 +++++ .../model/DTO/HistoryPageDto.java | 6 ++ .../services/HistoryService.java | 34 +++++++++++ .../controller/ExpenseListControllerTest.java | 41 ++++++++++++++ .../services/HistoryServiceTest.java | 56 ++++++++++++++++++- 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index f107299..d075ddd 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -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 getHistory( + @PathVariable("id") Long id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "30") int size, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional 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); diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java new file mode 100644 index 0000000..b598d9c --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java @@ -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 changes) { +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java new file mode 100644 index 0000000..76b8b81 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java @@ -0,0 +1,6 @@ +package de.zendric.app.xpensely_server.model.DTO; + +import java.util.List; + +public record HistoryPageDto(List entries, boolean hasMore) { +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java index 82e81f2..b16063b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java @@ -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 result = historyRepository + .findByExpenseListIdOrderByTimestampDescIdDesc(listId, + PageRequest.of(Math.max(page, 0), cappedSize)); + List entries = result.getContent().stream().map(this::toDto).toList(); + return new HistoryPageDto(entries, result.hasNext()); + } + + private HistoryEntryDto toDto(ExpenseHistoryEntry entry) { + List changes = List.of(); + if (entry.getChanges() != null) { + try { + changes = objectMapper.readValue(entry.getChanges(), + new TypeReference>() { + }); + } 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); + } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 5461ff2..6391b1f 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -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()); + } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java index 9c1b902..d1aca0f 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java @@ -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 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(); + } }