Dev #18
@@ -14,9 +14,11 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.*;
|
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.security.AuthenticatedUserResolver;
|
||||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||||
|
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/expenselist")
|
@RequestMapping("/api/expenselist")
|
||||||
@@ -27,12 +29,15 @@ public class ExpenseListController {
|
|||||||
private final ExpenseListService expenseListService;
|
private final ExpenseListService expenseListService;
|
||||||
private final CategoryService categoryService;
|
private final CategoryService categoryService;
|
||||||
private final AuthenticatedUserResolver authenticatedUserResolver;
|
private final AuthenticatedUserResolver authenticatedUserResolver;
|
||||||
|
private final HistoryService historyService;
|
||||||
|
|
||||||
public ExpenseListController(ExpenseListService expenseListService,
|
public ExpenseListController(ExpenseListService expenseListService,
|
||||||
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
|
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver,
|
||||||
|
HistoryService historyService) {
|
||||||
this.expenseListService = expenseListService;
|
this.expenseListService = expenseListService;
|
||||||
this.categoryService = categoryService;
|
this.categoryService = categoryService;
|
||||||
this.authenticatedUserResolver = authenticatedUserResolver;
|
this.authenticatedUserResolver = authenticatedUserResolver;
|
||||||
|
this.historyService = historyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mine")
|
@GetMapping("/mine")
|
||||||
@@ -175,6 +180,20 @@ public class ExpenseListController {
|
|||||||
return ResponseEntity.ok("User added to the list");
|
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) {
|
private void assertOwner(AppUser authenticated, ExpenseList list) {
|
||||||
if (!list.getOwner().getId().equals(authenticated.getId()))
|
if (!list.getOwner().getId().equals(authenticated.getId()))
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
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.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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.Expense;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||||
@@ -84,4 +89,33 @@ public class HistoryService {
|
|||||||
newVal == null ? null : String.valueOf(newVal)));
|
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.controller.ExpenseListController;
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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.Expense;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||||
|
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
|
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
|
||||||
@@ -38,6 +40,7 @@ class ExpenseListControllerTest {
|
|||||||
@MockitoBean ExpenseListService expenseListService;
|
@MockitoBean ExpenseListService expenseListService;
|
||||||
@MockitoBean CategoryService categoryService;
|
@MockitoBean CategoryService categoryService;
|
||||||
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
||||||
|
@MockitoBean HistoryService historyService;
|
||||||
|
|
||||||
// --- Validation tests ---
|
// --- Validation tests ---
|
||||||
|
|
||||||
@@ -281,4 +284,42 @@ class ExpenseListControllerTest {
|
|||||||
.content("{\"inviteCode\":\"ABC123\"}"))
|
.content("{\"inviteCode\":\"ABC123\"}"))
|
||||||
.andExpect(status().isConflict());
|
.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.Mock;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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 tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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.Expense;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
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 de.zendric.app.xpensely_server.services.HistoryService;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class HistoryServiceTest {
|
class HistoryServiceTest {
|
||||||
@@ -152,4 +158,52 @@ class HistoryServiceTest {
|
|||||||
assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
|
assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
|
||||||
assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi");
|
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