7 Commits

16 changed files with 772 additions and 13 deletions
@@ -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")
@@ -104,7 +109,7 @@ public class ExpenseListController {
assertMember(user, listOpt.get()); assertMember(user, listOpt.get());
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner()); AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense, user);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
} }
@@ -120,7 +125,7 @@ public class ExpenseListController {
assertMember(user, expenseListOpt.get()); assertMember(user, expenseListOpt.get());
AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName()); AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName());
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense, user);
return new ResponseEntity<>(updatedExpense, HttpStatus.OK); return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
} }
@@ -134,7 +139,7 @@ public class ExpenseListController {
if (listOpt.isEmpty()) if (listOpt.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND); return new ResponseEntity<>(HttpStatus.NOT_FOUND);
assertMember(user, listOpt.get()); assertMember(user, listOpt.get());
expenseListService.deleteExpenseFromList(expenseListId, expenseId); expenseListService.deleteExpenseFromList(expenseListId, expenseId, user);
return new ResponseEntity<>(HttpStatus.NO_CONTENT); return new ResponseEntity<>(HttpStatus.NO_CONTENT);
} }
@@ -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) {
}
@@ -1,6 +1,7 @@
package de.zendric.app.xpensely_server.model; package de.zendric.app.xpensely_server.model;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonBackReference; import com.fasterxml.jackson.annotation.JsonBackReference;
@@ -36,6 +37,7 @@ public class Expense {
private Double otherPersonAmount; private Double otherPersonAmount;
private String category; private String category;
private LocalDate date; private LocalDate date;
private LocalDateTime lastModified;
@ManyToOne @ManyToOne
@JoinColumn(name = "expense_list_id", nullable = false) @JoinColumn(name = "expense_list_id", nullable = false)
@@ -0,0 +1,52 @@
package de.zendric.app.xpensely_server.model;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "expense_history_entry", indexes = @Index(name = "idx_history_list_timestamp", columnList = "expense_list_id, timestamp DESC"))
public class ExpenseHistoryEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "expense_list_id", nullable = false)
private ExpenseList expenseList;
/** Plain column on purpose — no FK, so history survives expense deletion. */
private Long expenseId;
/** Denormalized so entries for deleted expenses still render. */
private String expenseTitle;
@ManyToOne
private AppUser actor;
@Enumerated(EnumType.STRING)
private HistoryEntryType type;
private LocalDateTime timestamp;
/** JSON array of FieldChange; null for CREATED/DELETED. */
@Column(columnDefinition = "TEXT")
private String changes;
}
@@ -0,0 +1,9 @@
package de.zendric.app.xpensely_server.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record FieldChange(
String field,
@JsonProperty("old") String oldValue,
@JsonProperty("new") String newValue) {
}
@@ -0,0 +1,5 @@
package de.zendric.app.xpensely_server.model;
public enum HistoryEntryType {
CREATED, UPDATED, DELETED
}
@@ -0,0 +1,61 @@
package de.zendric.app.xpensely_server.preparation;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.HistoryEntryType;
import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
/**
* One-time (idempotent) backfill for the history feature: fills
* Expense.lastModified from the expense date and creates a synthetic CREATED
* history entry for every expense that predates change logging. Both steps
* are no-ops on every startup after the first.
*/
@Component
public class HistoryBackfillRunner implements CommandLineRunner {
private final ExpenseRepository expenseRepository;
private final ExpenseHistoryRepository historyRepository;
public HistoryBackfillRunner(ExpenseRepository expenseRepository,
ExpenseHistoryRepository historyRepository) {
this.expenseRepository = expenseRepository;
this.historyRepository = historyRepository;
}
@Override
@Transactional
public void run(String... args) {
for (Expense expense : expenseRepository.findByLastModifiedIsNull()) {
expense.setLastModified(startOfDayOrNow(expense));
expenseRepository.save(expense);
}
Set<Long> loggedExpenseIds = new HashSet<>(historyRepository.findAllExpenseIds());
for (Expense expense : expenseRepository.findAll()) {
if (loggedExpenseIds.contains(expense.getId()))
continue;
ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
entry.setExpenseList(expense.getExpenseList());
entry.setExpenseId(expense.getId());
entry.setExpenseTitle(expense.getTitle());
entry.setActor(expense.getOwner());
entry.setType(HistoryEntryType.CREATED);
entry.setTimestamp(startOfDayOrNow(expense));
historyRepository.save(entry);
}
}
private LocalDateTime startOfDayOrNow(Expense expense) {
return expense.getDate() == null ? LocalDateTime.now() : expense.getDate().atStartOfDay();
}
}
@@ -0,0 +1,20 @@
package de.zendric.app.xpensely_server.repo;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
public interface ExpenseHistoryRepository extends JpaRepository<ExpenseHistoryEntry, Long> {
Page<ExpenseHistoryEntry> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
@Query("select h.expenseId from ExpenseHistoryEntry h")
List<Long> findAllExpenseIds();
void deleteByExpenseListId(Long expenseListId);
}
@@ -10,4 +10,6 @@ import de.zendric.app.xpensely_server.model.Expense;
@Repository @Repository
public interface ExpenseRepository extends JpaRepository<Expense, Long> { public interface ExpenseRepository extends JpaRepository<Expense, Long> {
List<Expense> findAllByOrderByDateAsc(); List<Expense> findAllByOrderByDateAsc();
List<Expense> findByLastModifiedIsNull();
} }
@@ -8,6 +8,7 @@ import java.util.UUID;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import de.zendric.app.xpensely_server.model.AppUser;
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.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
@@ -23,12 +24,14 @@ public class ExpenseListService {
private final ExpenseListRepository repository; private final ExpenseListRepository repository;
private final ExpenseRepository expenseRepository; private final ExpenseRepository expenseRepository;
private final XpenselyCustomCategoryRepository customCategoryRepository; private final XpenselyCustomCategoryRepository customCategoryRepository;
private final HistoryService historyService;
public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository, public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository,
XpenselyCustomCategoryRepository customCategoryRepository) { XpenselyCustomCategoryRepository customCategoryRepository, HistoryService historyService) {
this.repository = repository; this.repository = repository;
this.expenseRepository = expenseRepository; this.expenseRepository = expenseRepository;
this.customCategoryRepository = customCategoryRepository; this.customCategoryRepository = customCategoryRepository;
this.historyService = historyService;
} }
public ExpenseList createList(ExpenseList list) { public ExpenseList createList(ExpenseList list) {
@@ -36,6 +39,7 @@ public class ExpenseListService {
} }
public void deleteById(Long id) { public void deleteById(Long id) {
historyService.deleteForList(id);
repository.deleteById(id); repository.deleteById(id);
} }
@@ -62,15 +66,17 @@ public class ExpenseListService {
return repository.findByOwnerUsernameOrSharedWithUsername(username); return repository.findByOwnerUsernameOrSharedWithUsername(username);
} }
public Expense addExpenseToList(Long expenseListId, Expense expense) { public Expense addExpenseToList(Long expenseListId, Expense expense, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId) ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
expense.setLastModified(LocalDateTime.now());
expenseList.addExpense(expense); expenseList.addExpense(expense);
repository.save(expenseList); repository.save(expenseList);
historyService.recordCreated(expenseList, expense, actor);
return expense; return expense;
} }
public void deleteExpenseFromList(Long expenseListId, Long expenseId) { public void deleteExpenseFromList(Long expenseListId, Long expenseId, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId) ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
Expense expenseToRemove = null; Expense expenseToRemove = null;
@@ -80,11 +86,11 @@ public class ExpenseListService {
break; break;
} }
} }
if (expenseToRemove != null) { if (expenseToRemove == null) {
expenseList.removeExpense(expenseToRemove);
} else {
throw new ResourceNotFoundException("Expense not found with id: " + expenseId); throw new ResourceNotFoundException("Expense not found with id: " + expenseId);
} }
historyService.recordDeleted(expenseList, expenseToRemove, actor);
expenseList.removeExpense(expenseToRemove);
repository.save(expenseList); repository.save(expenseList);
} }
@@ -110,7 +116,7 @@ public class ExpenseListService {
return repository.findByInviteCode(inviteCode); return repository.findByInviteCode(inviteCode);
} }
public Expense updateExpense(Long expenseListId, Expense updatedExpense) { public Expense updateExpense(Long expenseListId, Expense updatedExpense, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId) ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
@@ -121,6 +127,9 @@ public class ExpenseListService {
Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) Expense existingExpense = expenseRepository.findById(updatedExpense.getId())
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId())); .orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId()));
Expense before = snapshot(existingExpense);
existingExpense.setTitle(updatedExpense.getTitle()); existingExpense.setTitle(updatedExpense.getTitle());
existingExpense.setAmount(updatedExpense.getAmount()); existingExpense.setAmount(updatedExpense.getAmount());
existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount());
@@ -128,8 +137,25 @@ public class ExpenseListService {
existingExpense.setDate(updatedExpense.getDate()); existingExpense.setDate(updatedExpense.getDate());
existingExpense.setOwner(updatedExpense.getOwner()); existingExpense.setOwner(updatedExpense.getOwner());
existingExpense.setCategory(updatedExpense.getCategory()); existingExpense.setCategory(updatedExpense.getCategory());
existingExpense.setLastModified(LocalDateTime.now());
return expenseRepository.save(existingExpense); Expense saved = expenseRepository.save(existingExpense);
historyService.recordUpdated(expenseList, before, saved, actor);
return saved;
}
/** Detached copy of the mutable fields, taken before applying an update. */
private Expense snapshot(Expense e) {
Expense copy = new Expense();
copy.setId(e.getId());
copy.setTitle(e.getTitle());
copy.setAmount(e.getAmount());
copy.setPersonalUseAmount(e.getPersonalUseAmount());
copy.setOtherPersonAmount(e.getOtherPersonAmount());
copy.setCategory(e.getCategory());
copy.setDate(e.getDate());
copy.setOwner(e.getOwner());
return copy;
} }
// TODO implement API for this // TODO implement API for this
@@ -0,0 +1,125 @@
package de.zendric.app.xpensely_server.services;
import java.time.LocalDateTime;
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;
import de.zendric.app.xpensely_server.model.FieldChange;
import de.zendric.app.xpensely_server.model.HistoryEntryType;
import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
@Service
@Transactional
public class HistoryService {
private final ExpenseHistoryRepository historyRepository;
private final ObjectMapper objectMapper;
public HistoryService(ExpenseHistoryRepository historyRepository, ObjectMapper objectMapper) {
this.historyRepository = historyRepository;
this.objectMapper = objectMapper;
}
public void recordCreated(ExpenseList list, Expense expense, AppUser actor) {
historyRepository.save(baseEntry(list, expense, actor, HistoryEntryType.CREATED));
}
public void recordUpdated(ExpenseList list, Expense before, Expense after, AppUser actor) {
List<FieldChange> changes = diff(before, after);
if (changes.isEmpty())
return;
ExpenseHistoryEntry entry = baseEntry(list, after, actor, HistoryEntryType.UPDATED);
try {
entry.setChanges(objectMapper.writeValueAsString(changes));
} catch (JacksonException e) {
throw new IllegalStateException("Failed to serialize history changes", e);
}
historyRepository.save(entry);
}
public void recordDeleted(ExpenseList list, Expense expense, AppUser actor) {
historyRepository.save(baseEntry(list, expense, actor, HistoryEntryType.DELETED));
}
public void deleteForList(Long listId) {
historyRepository.deleteByExpenseListId(listId);
}
public List<FieldChange> diff(Expense before, Expense after) {
List<FieldChange> changes = new ArrayList<>();
addIfChanged(changes, "title", before.getTitle(), after.getTitle());
addIfChanged(changes, "amount", before.getAmount(), after.getAmount());
addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount());
addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount());
addIfChanged(changes, "category", before.getCategory(), after.getCategory());
addIfChanged(changes, "date", before.getDate(), after.getDate());
addIfChanged(changes, "owner",
before.getOwner() == null ? null : before.getOwner().getUsername(),
after.getOwner() == null ? null : after.getOwner().getUsername());
return changes;
}
private ExpenseHistoryEntry baseEntry(ExpenseList list, Expense expense, AppUser actor, HistoryEntryType type) {
ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
entry.setExpenseList(list);
entry.setExpenseId(expense.getId());
entry.setExpenseTitle(expense.getTitle());
entry.setActor(actor);
entry.setType(type);
entry.setTimestamp(LocalDateTime.now());
return entry;
}
private void addIfChanged(List<FieldChange> changes, String field, Object oldVal, Object newVal) {
if (!Objects.equals(oldVal, newVal)) {
changes.add(new FieldChange(field,
oldVal == null ? null : String.valueOf(oldVal),
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.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 ---
@@ -199,7 +202,7 @@ class ExpenseListControllerTest {
when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member); when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense()); when(expenseListService.addExpenseToList(eq(1L), any(), any())).thenReturn(new Expense());
mockMvc.perform(post("/api/expenselist/1/add") mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -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());
}
} }
@@ -0,0 +1,92 @@
package de.zendric.app.xpensely_Server.preparation;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.model.HistoryEntryType;
import de.zendric.app.xpensely_server.preparation.HistoryBackfillRunner;
import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HistoryBackfillRunnerTest {
@Mock ExpenseRepository expenseRepository;
@Mock ExpenseHistoryRepository historyRepository;
@InjectMocks
HistoryBackfillRunner runner;
private Expense expense(long id, LocalDate date) {
AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
ExpenseList list = new ExpenseList(); list.setId(10L);
Expense e = new Expense();
e.setId(id);
e.setTitle("Groceries");
e.setDate(date);
e.setOwner(owner);
e.setExpenseList(list);
return e;
}
@Test
void fillsLastModifiedFromDate_forExpensesMissingIt() throws Exception {
Expense e = expense(42L, LocalDate.of(2026, 6, 1));
when(expenseRepository.findByLastModifiedIsNull()).thenReturn(List.of(e));
when(expenseRepository.findAll()).thenReturn(List.of(e));
when(historyRepository.findAllExpenseIds()).thenReturn(List.of(42L));
runner.run();
assertThat(e.getLastModified()).isEqualTo(LocalDate.of(2026, 6, 1).atStartOfDay());
verify(expenseRepository).save(e);
}
@Test
void createsSyntheticCreatedEntry_forExpensesWithoutHistory() throws Exception {
Expense e = expense(42L, LocalDate.of(2026, 6, 1));
when(expenseRepository.findByLastModifiedIsNull()).thenReturn(List.of());
when(expenseRepository.findAll()).thenReturn(List.of(e));
when(historyRepository.findAllExpenseIds()).thenReturn(List.of());
runner.run();
ArgumentCaptor<ExpenseHistoryEntry> captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
verify(historyRepository).save(captor.capture());
ExpenseHistoryEntry entry = captor.getValue();
assertThat(entry.getType()).isEqualTo(HistoryEntryType.CREATED);
assertThat(entry.getExpenseId()).isEqualTo(42L);
assertThat(entry.getExpenseTitle()).isEqualTo("Groceries");
assertThat(entry.getActor().getUsername()).isEqualTo("alice");
assertThat(entry.getTimestamp()).isEqualTo(LocalDate.of(2026, 6, 1).atStartOfDay());
}
@Test
void isIdempotent_skipsExpensesThatAlreadyHaveHistory() throws Exception {
Expense e = expense(42L, LocalDate.of(2026, 6, 1));
when(expenseRepository.findByLastModifiedIsNull()).thenReturn(List.of());
when(expenseRepository.findAll()).thenReturn(List.of(e));
when(historyRepository.findAllExpenseIds()).thenReturn(List.of(42L));
runner.run();
verify(historyRepository, never()).save(any());
}
}
@@ -1,20 +1,27 @@
package de.zendric.app.xpensely_Server.services; package de.zendric.app.xpensely_Server.services;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUser;
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.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseListRepository;
import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository;
import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository;
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.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -25,6 +32,7 @@ class ExpenseListServiceTest {
@Mock ExpenseListRepository repository; @Mock ExpenseListRepository repository;
@Mock ExpenseRepository expenseRepository; @Mock ExpenseRepository expenseRepository;
@Mock XpenselyCustomCategoryRepository customCategoryRepository; @Mock XpenselyCustomCategoryRepository customCategoryRepository;
@Mock HistoryService historyService;
@InjectMocks @InjectMocks
ExpenseListService service; ExpenseListService service;
@@ -54,4 +62,72 @@ class ExpenseListServiceTest {
verify(repository).findByOwnerUsernameOrSharedWithUsername("alice"); verify(repository).findByOwnerUsernameOrSharedWithUsername("alice");
verify(repository, never()).findAll(); verify(repository, never()).findAll();
} }
private Expense makeExpense(Long id, String title, double amount) {
Expense e = new Expense();
e.setId(id);
e.setTitle(title);
e.setAmount(amount);
e.setPersonalUseAmount(0.0);
e.setOtherPersonAmount(0.0);
e.setDate(LocalDate.of(2026, 7, 1));
return e;
}
@Test
void addExpenseToList_setsLastModified_andRecordsCreated() {
AppUser actor = new AppUser(); actor.setId(1L);
ExpenseList list = new ExpenseList(); list.setId(10L);
list.setExpenses(new ArrayList<>());
when(repository.findById(10L)).thenReturn(Optional.of(list));
Expense expense = makeExpense(null, "Lunch", 10.0);
service.addExpenseToList(10L, expense, actor);
assertThat(expense.getLastModified()).isNotNull();
verify(historyService).recordCreated(list, expense, actor);
}
@Test
void updateExpense_setsLastModified_andRecordsUpdatedWithBeforeSnapshot() {
AppUser actor = new AppUser(); actor.setId(1L);
ExpenseList list = new ExpenseList(); list.setId(10L);
Expense existing = makeExpense(42L, "Old title", 12.0);
list.setExpenses(new ArrayList<>(List.of(existing)));
when(repository.findById(10L)).thenReturn(Optional.of(list));
when(expenseRepository.findById(42L)).thenReturn(Optional.of(existing));
when(expenseRepository.save(any(Expense.class))).thenAnswer(inv -> inv.getArgument(0));
Expense updated = makeExpense(42L, "New title", 15.0);
service.updateExpense(10L, updated, actor);
assertThat(existing.getLastModified()).isNotNull();
org.mockito.ArgumentCaptor<Expense> beforeCaptor =
org.mockito.ArgumentCaptor.forClass(Expense.class);
verify(historyService).recordUpdated(eq(list), beforeCaptor.capture(), eq(existing), eq(actor));
assertThat(beforeCaptor.getValue().getTitle()).isEqualTo("Old title");
assertThat(beforeCaptor.getValue().getAmount()).isEqualTo(12.0);
}
@Test
void deleteById_deletesHistoryEntriesBeforeDeletingList() {
service.deleteById(10L);
org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(historyService, repository);
inOrder.verify(historyService).deleteForList(10L);
inOrder.verify(repository).deleteById(10L);
}
@Test
void deleteExpenseFromList_recordsDeleted() {
AppUser actor = new AppUser(); actor.setId(1L);
ExpenseList list = new ExpenseList(); list.setId(10L);
Expense existing = makeExpense(42L, "Taxi", 9.0);
list.setExpenses(new ArrayList<>(List.of(existing)));
when(repository.findById(10L)).thenReturn(Optional.of(list));
service.deleteExpenseFromList(10L, 42L, actor);
verify(historyService).recordDeleted(eq(list), any(Expense.class), eq(actor));
}
} }
@@ -0,0 +1,209 @@
package de.zendric.app.xpensely_Server.services;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
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;
import de.zendric.app.xpensely_server.model.FieldChange;
import de.zendric.app.xpensely_server.model.HistoryEntryType;
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.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HistoryServiceTest {
@Mock ExpenseHistoryRepository historyRepository;
@Spy ObjectMapper objectMapper = new ObjectMapper();
@InjectMocks
HistoryService service;
private AppUser user(long id, String name) {
AppUser u = new AppUser();
u.setId(id);
u.setUsername(name);
return u;
}
private Expense expense(String title, double amount, String category, LocalDate date, AppUser owner) {
Expense e = new Expense();
e.setId(42L);
e.setTitle(title);
e.setAmount(amount);
e.setPersonalUseAmount(0.0);
e.setOtherPersonAmount(0.0);
e.setCategory(category);
e.setDate(date);
e.setOwner(owner);
return e;
}
@Test
void diff_detectsChangedFields_withOldAndNewValues() {
AppUser alice = user(1L, "alice");
Expense before = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
Expense after = expense("Groceries", 15.5, "Household", LocalDate.of(2026, 7, 1), alice);
List<FieldChange> changes = service.diff(before, after);
assertThat(changes).containsExactlyInAnyOrder(
new FieldChange("amount", "12.0", "15.5"),
new FieldChange("category", "Food", "Household"));
}
@Test
void diff_ownerChange_isReportedByUsername() {
Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice"));
Expense after = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(2L, "ben"));
List<FieldChange> changes = service.diff(before, after);
assertThat(changes).containsExactly(new FieldChange("owner", "alice", "ben"));
}
@Test
void diff_nullToValue_isReported() {
Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice"));
Expense after = expense("Taxi", 9.0, "Transportation", LocalDate.of(2026, 7, 1), user(1L, "alice"));
assertThat(service.diff(before, after))
.containsExactly(new FieldChange("category", null, "Transportation"));
}
@Test
void recordUpdated_noChanges_writesNothing() {
AppUser alice = user(1L, "alice");
Expense same = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
ExpenseList list = new ExpenseList();
list.setId(5L);
service.recordUpdated(list, same, same, alice);
verify(historyRepository, never()).save(any());
}
@Test
void recordUpdated_savesEntryWithJsonChanges() {
AppUser alice = user(1L, "alice");
ExpenseList list = new ExpenseList();
list.setId(5L);
Expense before = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
Expense after = expense("Groceries", 15.5, "Food", LocalDate.of(2026, 7, 1), alice);
service.recordUpdated(list, before, after, alice);
ArgumentCaptor<ExpenseHistoryEntry> captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
verify(historyRepository).save(captor.capture());
ExpenseHistoryEntry entry = captor.getValue();
assertThat(entry.getType()).isEqualTo(HistoryEntryType.UPDATED);
assertThat(entry.getExpenseId()).isEqualTo(42L);
assertThat(entry.getExpenseTitle()).isEqualTo("Groceries");
assertThat(entry.getActor()).isEqualTo(alice);
assertThat(entry.getExpenseList()).isEqualTo(list);
assertThat(entry.getTimestamp()).isNotNull();
assertThat(entry.getChanges())
.isEqualTo("[{\"field\":\"amount\",\"old\":\"12.0\",\"new\":\"15.5\"}]");
}
@Test
void recordCreated_savesEntryWithoutChanges() {
AppUser alice = user(1L, "alice");
ExpenseList list = new ExpenseList();
list.setId(5L);
Expense e = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
service.recordCreated(list, e, alice);
ArgumentCaptor<ExpenseHistoryEntry> captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
verify(historyRepository).save(captor.capture());
assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.CREATED);
assertThat(captor.getValue().getChanges()).isNull();
}
@Test
void recordDeleted_savesEntry() {
AppUser alice = user(1L, "alice");
ExpenseList list = new ExpenseList();
list.setId(5L);
Expense e = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), alice);
service.recordDeleted(list, e, alice);
ArgumentCaptor<ExpenseHistoryEntry> captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
verify(historyRepository).save(captor.capture());
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();
}
}