From 9d1cf862c719dc547c3c23a14a12aa032ca2860c Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:21:05 +0200 Subject: [PATCH 01/11] feat: history entity, repository and Expense.lastModified --- .../app/xpensely_server/model/Expense.java | 2 + .../model/ExpenseHistoryEntry.java | 52 +++++++++++++++++++ .../xpensely_server/model/FieldChange.java | 9 ++++ .../model/HistoryEntryType.java | 5 ++ .../repo/ExpenseHistoryRepository.java | 18 +++++++ .../repo/ExpenseRepository.java | 2 + 6 files changed, 88 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java index 9e97e24..34bc45f 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java @@ -1,6 +1,7 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; +import java.time.LocalDateTime; import com.fasterxml.jackson.annotation.JsonBackReference; @@ -36,6 +37,7 @@ public class Expense { private Double otherPersonAmount; private String category; private LocalDate date; + private LocalDateTime lastModified; @ManyToOne @JoinColumn(name = "expense_list_id", nullable = false) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java new file mode 100644 index 0000000..5e74989 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java @@ -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; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java b/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java new file mode 100644 index 0000000..8976242 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java @@ -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) { +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java b/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java new file mode 100644 index 0000000..a47f993 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java @@ -0,0 +1,5 @@ +package de.zendric.app.xpensely_server.model; + +public enum HistoryEntryType { + CREATED, UPDATED, DELETED +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java new file mode 100644 index 0000000..0009cca --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java @@ -0,0 +1,18 @@ +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 { + + Page findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable); + + @Query("select h.expenseId from ExpenseHistoryEntry h") + List findAllExpenseIds(); +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java index fe7a610..659d240 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java @@ -10,4 +10,6 @@ import de.zendric.app.xpensely_server.model.Expense; @Repository public interface ExpenseRepository extends JpaRepository { List findAllByOrderByDateAsc(); + + List findByLastModifiedIsNull(); } From 8f42add0c202d7ffd6593d26415461cb8ba55dbe Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:27:55 +0200 Subject: [PATCH 02/11] feat: HistoryService with field-level diff logic --- pom.xml | 4 + .../services/HistoryService.java | 87 ++++++++++ .../services/HistoryServiceTest.java | 155 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java diff --git a/pom.xml b/pom.xml index 9d421c4..73bd9ed 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,10 @@ lombok true + + com.fasterxml.jackson.core + jackson-databind + org.springframework.boot spring-boot-starter-test 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 new file mode 100644 index 0000000..a07cdf0 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java @@ -0,0 +1,87 @@ +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +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.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 changes = diff(before, after); + if (changes.isEmpty()) + return; + ExpenseHistoryEntry entry = baseEntry(list, after, actor, HistoryEntryType.UPDATED); + try { + entry.setChanges(objectMapper.writeValueAsString(changes)); + } catch (JsonProcessingException 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 List diff(Expense before, Expense after) { + List 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 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))); + } + } +} 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 new file mode 100644 index 0000000..9d512a0 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java @@ -0,0 +1,155 @@ +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 com.fasterxml.jackson.databind.ObjectMapper; + +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.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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.any; + +@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 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 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 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 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 captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class); + verify(historyRepository).save(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED); + assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi"); + } +} From 27f747b44da6de5d8e0ebe3b006e24a0f67e7b97 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:35:39 +0200 Subject: [PATCH 03/11] fix: migrate HistoryService to Jackson 3 (Spring Boot 4) --- pom.xml | 4 ---- .../app/xpensely_server/services/HistoryService.java | 6 +++--- .../app/xpensely_Server/services/HistoryServiceTest.java | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 73bd9ed..9d421c4 100644 --- a/pom.xml +++ b/pom.xml @@ -72,10 +72,6 @@ lombok true - - com.fasterxml.jackson.core - jackson-databind - org.springframework.boot spring-boot-starter-test 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 a07cdf0..82e81f2 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 @@ -8,8 +8,8 @@ import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; @@ -42,7 +42,7 @@ public class HistoryService { ExpenseHistoryEntry entry = baseEntry(list, after, actor, HistoryEntryType.UPDATED); try { entry.setChanges(objectMapper.writeValueAsString(changes)); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to serialize history changes", e); } historyRepository.save(entry); 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 9d512a0..9c1b902 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 @@ -11,7 +11,7 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; From b19cdd026408b321cc1f8f72613639ffab4e88b1 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:40:32 +0200 Subject: [PATCH 04/11] feat: record history and lastModified on expense mutations --- .../controller/ExpenseListController.java | 6 +- .../services/ExpenseListService.java | 41 +++++++++--- .../controller/ExpenseListControllerTest.java | 2 +- .../services/ExpenseListServiceTest.java | 67 +++++++++++++++++++ 4 files changed, 104 insertions(+), 12 deletions(-) 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 03ce5a9..f107299 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 @@ -104,7 +104,7 @@ public class ExpenseListController { assertMember(user, listOpt.get()); AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner()); 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); } @@ -120,7 +120,7 @@ public class ExpenseListController { assertMember(user, expenseListOpt.get()); AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName()); 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); } @@ -134,7 +134,7 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); - expenseListService.deleteExpenseFromList(expenseListId, expenseId); + expenseListService.deleteExpenseFromList(expenseListId, expenseId, user); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 7ab97f7..e090e4d 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -8,6 +8,7 @@ import java.util.UUID; import org.springframework.stereotype.Service; 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.ExpenseList; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; @@ -23,12 +24,14 @@ public class ExpenseListService { private final ExpenseListRepository repository; private final ExpenseRepository expenseRepository; private final XpenselyCustomCategoryRepository customCategoryRepository; + private final HistoryService historyService; public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository, - XpenselyCustomCategoryRepository customCategoryRepository) { + XpenselyCustomCategoryRepository customCategoryRepository, HistoryService historyService) { this.repository = repository; this.expenseRepository = expenseRepository; this.customCategoryRepository = customCategoryRepository; + this.historyService = historyService; } public ExpenseList createList(ExpenseList list) { @@ -62,15 +65,17 @@ public class ExpenseListService { 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) .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); + expense.setLastModified(LocalDateTime.now()); expenseList.addExpense(expense); repository.save(expenseList); + historyService.recordCreated(expenseList, expense, actor); return expense; } - public void deleteExpenseFromList(Long expenseListId, Long expenseId) { + public void deleteExpenseFromList(Long expenseListId, Long expenseId, AppUser actor) { ExpenseList expenseList = repository.findById(expenseListId) .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); Expense expenseToRemove = null; @@ -80,11 +85,11 @@ public class ExpenseListService { break; } } - if (expenseToRemove != null) { - expenseList.removeExpense(expenseToRemove); - } else { + if (expenseToRemove == null) { throw new ResourceNotFoundException("Expense not found with id: " + expenseId); } + historyService.recordDeleted(expenseList, expenseToRemove, actor); + expenseList.removeExpense(expenseToRemove); repository.save(expenseList); } @@ -110,7 +115,7 @@ public class ExpenseListService { 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) .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); @@ -121,6 +126,9 @@ public class ExpenseListService { Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) .orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId())); + + Expense before = snapshot(existingExpense); + existingExpense.setTitle(updatedExpense.getTitle()); existingExpense.setAmount(updatedExpense.getAmount()); existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); @@ -128,8 +136,25 @@ public class ExpenseListService { existingExpense.setDate(updatedExpense.getDate()); existingExpense.setOwner(updatedExpense.getOwner()); 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 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 e203b64..5461ff2 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 @@ -199,7 +199,7 @@ class ExpenseListControllerTest { when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); 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") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java index 0f832a5..ad7a25f 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -1,20 +1,27 @@ package de.zendric.app.xpensely_Server.services; 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.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; 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.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; 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.when; import static org.mockito.Mockito.never; @@ -25,6 +32,7 @@ class ExpenseListServiceTest { @Mock ExpenseListRepository repository; @Mock ExpenseRepository expenseRepository; @Mock XpenselyCustomCategoryRepository customCategoryRepository; + @Mock HistoryService historyService; @InjectMocks ExpenseListService service; @@ -54,4 +62,63 @@ class ExpenseListServiceTest { verify(repository).findByOwnerUsernameOrSharedWithUsername("alice"); 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 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 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)); + } } From b8ca1b94ec798de4f219de107213a6a6598a81be Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:46:46 +0200 Subject: [PATCH 05/11] 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(); + } } From 765c5d5b32abedcbdacc94c1976f7c6aecaa7509 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:50:33 +0200 Subject: [PATCH 06/11] feat: idempotent startup backfill for lastModified and history --- .../preparation/HistoryBackfillRunner.java | 61 ++++++++++++ .../HistoryBackfillRunnerTest.java | 92 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/preparation/HistoryBackfillRunner.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/preparation/HistoryBackfillRunnerTest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/preparation/HistoryBackfillRunner.java b/src/main/java/de/zendric/app/xpensely_server/preparation/HistoryBackfillRunner.java new file mode 100644 index 0000000..49eca0e --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/preparation/HistoryBackfillRunner.java @@ -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 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(); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/preparation/HistoryBackfillRunnerTest.java b/src/test/java/de/zendric/app/xpensely_Server/preparation/HistoryBackfillRunnerTest.java new file mode 100644 index 0000000..65e287f --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/preparation/HistoryBackfillRunnerTest.java @@ -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 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()); + } +} From ac0118d435d5e3a21bf6201a98d93a77047fb283 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 13:20:10 +0200 Subject: [PATCH 07/11] fix: delete history entries when their expense list is deleted --- .../xpensely_server/repo/ExpenseHistoryRepository.java | 2 ++ .../app/xpensely_server/services/ExpenseListService.java | 1 + .../app/xpensely_server/services/HistoryService.java | 4 ++++ .../xpensely_Server/services/ExpenseListServiceTest.java | 9 +++++++++ 4 files changed, 16 insertions(+) diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java index 0009cca..d6f283e 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java @@ -15,4 +15,6 @@ public interface ExpenseHistoryRepository extends JpaRepository findAllExpenseIds(); + + void deleteByExpenseListId(Long expenseListId); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index e090e4d..57756e7 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -39,6 +39,7 @@ public class ExpenseListService { } public void deleteById(Long id) { + historyService.deleteForList(id); repository.deleteById(id); } 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 b16063b..8cd65ec 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 @@ -57,6 +57,10 @@ public class HistoryService { historyRepository.save(baseEntry(list, expense, actor, HistoryEntryType.DELETED)); } + public void deleteForList(Long listId) { + historyRepository.deleteByExpenseListId(listId); + } + public List diff(Expense before, Expense after) { List changes = new ArrayList<>(); addIfChanged(changes, "title", before.getTitle(), after.getTitle()); diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java index ad7a25f..7c30ef6 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -109,6 +109,15 @@ class ExpenseListServiceTest { 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); From b58008c8bc4ed4a84cb18befdb3bf64ba93db980 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 18:52:42 +0200 Subject: [PATCH 08/11] fix: allow editing quick-add stub expenses (amount 0, no category) Quick Add deliberately creates stub expenses with amount 0 and no category (ExpenseInput allows both), but ExpenseChangeRequest required amount >= 0.01 and a non-blank category, so every edit of a stub that did not simultaneously fill in both fields was rejected with 400. Update validation now accepts the same states create can produce, and a blank category is normalized to null. Co-Authored-By: Claude Fable 5 --- .../model/ExpenseChangeRequest.java | 9 ++-- .../controller/ExpenseListControllerTest.java | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 20c161b..561ff11 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -24,8 +24,10 @@ public class ExpenseChangeRequest { @NotBlank(message = "Owner name is required") private String ownerName; + // Same bounds as ExpenseInput: Quick Add creates stub expenses with amount 0 + // and no category, and those stubs must remain editable. @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + @DecimalMin(value = "0.00", message = "Amount must not be negative") private Double amount; private Double personalUseAmount; @@ -34,7 +36,6 @@ public class ExpenseChangeRequest { @NotNull(message = "Date is required") private LocalDate date; - @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { @@ -51,7 +52,9 @@ public class ExpenseChangeRequest { expense.setId(id); expense.setOwner(appUser); expense.setTitle(title); - expense.setCategory(category); + // The edit dialog sends "" for a missing category; store null so the + // stub state stays uniform and history diffs don't report null -> "". + expense.setCategory(category == null || category.isBlank() ? null : category); return expense; } 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 6391b1f..aba6b13 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 @@ -53,6 +53,54 @@ class ExpenseListControllerTest { .andExpect(jsonPath("$.title").exists()); } + // Quick Add creates stub expenses (amount 0, no category); updating such a + // stub must be allowed so the user can fill in the details afterwards. + @Test + void updateExpense_quickAddStub_zeroAmountAndNoCategory_returns200() 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(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner))) + .thenAnswer(inv -> inv.getArgument(1)); + + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\"}")) + .andExpect(status().isOk()); + } + + @Test + void updateExpense_blankCategory_isStoredAsNull() 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(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner))) + .thenAnswer(inv -> inv.getArgument(1)); + + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"\"}")) + .andExpect(status().isOk()); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Expense.class); + org.mockito.Mockito.verify(expenseListService).updateExpense(eq(1L), captor.capture(), eq(owner)); + org.assertj.core.api.Assertions.assertThat(captor.getValue().getCategory()).isNull(); + } + + @Test + void updateExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":-5.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + @Test void addExpense_negativeAmount_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/1/add") From b7e0fa1c8be6ba60caee2f3054a49294dcf29c83 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 18:59:32 +0200 Subject: [PATCH 09/11] feat: add cross-list recent history query and service method --- .../model/DTO/RecentHistoryEntryDto.java | 12 ++++ .../repo/ExpenseHistoryRepository.java | 3 + .../services/HistoryService.java | 18 +++++ .../services/HistoryServiceTest.java | 72 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java new file mode 100644 index 0000000..6faccfd --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java @@ -0,0 +1,12 @@ +package de.zendric.app.xpensely_server.model.DTO; + +import java.time.LocalDateTime; + +public record RecentHistoryEntryDto( + String type, + String actorUsername, + String expenseTitle, + LocalDateTime timestamp, + Long listId, + String listName) { +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java index d6f283e..e6c5f6d 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java @@ -13,6 +13,9 @@ public interface ExpenseHistoryRepository extends JpaRepository findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable); + Page findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + Long ownerId, Long sharedWithId, Pageable pageable); + @Query("select h.expenseId from ExpenseHistoryEntry h") List findAllExpenseIds(); 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 8cd65ec..6ea8ebf 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 @@ -17,6 +17,7 @@ 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.DTO.RecentHistoryEntryDto; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseList; @@ -104,6 +105,23 @@ public class HistoryService { return new HistoryPageDto(entries, result.hasNext()); } + @Transactional(readOnly = true) + public List getRecentForUser(AppUser user, int limit) { + int cappedLimit = Math.min(Math.max(limit, 1), 20); + Page result = historyRepository + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + user.getId(), user.getId(), PageRequest.of(0, cappedLimit)); + return result.getContent().stream() + .map(e -> new RecentHistoryEntryDto( + e.getType().name(), + e.getActor() == null ? null : e.getActor().getUsername(), + e.getExpenseTitle(), + e.getTimestamp(), + e.getExpenseList().getId(), + e.getExpenseList().getName())) + .toList(); + } + private HistoryEntryDto toDto(ExpenseHistoryEntry entry) { List changes = List.of(); if (entry.getChanges() != null) { 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 d1aca0f..7301a35 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 @@ -18,6 +18,7 @@ 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.DTO.RecentHistoryEntryDto; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseList; @@ -206,4 +207,75 @@ class HistoryServiceTest { assertThat(page.entries().get(0).changes()).isEmpty(); } + + private ExpenseHistoryEntry historyEntry(Long id, ExpenseList list, AppUser actor, + HistoryEntryType type, String title, java.time.LocalDateTime timestamp) { + ExpenseHistoryEntry e = new ExpenseHistoryEntry(); + e.setId(id); + e.setExpenseList(list); + e.setActor(actor); + e.setType(type); + e.setExpenseTitle(title); + e.setTimestamp(timestamp); + return e; + } + + @Test + void getRecentForUser_mapsEntriesWithListIdAndName() { + AppUser alice = user(1L, "alice"); + ExpenseList list = new ExpenseList(); + list.setId(10L); + list.setName("Trip to Rome"); + ExpenseHistoryEntry entry = historyEntry(100L, list, alice, + HistoryEntryType.UPDATED, "Groceries", java.time.LocalDateTime.of(2026, 7, 6, 12, 0)); + + when(historyRepository + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + eq(1L), eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entry))); + + List result = service.getRecentForUser(alice, 4); + + assertThat(result).containsExactly(new RecentHistoryEntryDto( + "UPDATED", "alice", "Groceries", + java.time.LocalDateTime.of(2026, 7, 6, 12, 0), 10L, "Trip to Rome")); + } + + @Test + void getRecentForUser_nullActor_mapsToNullUsername() { + AppUser alice = user(1L, "alice"); + ExpenseList list = new ExpenseList(); + list.setId(10L); + list.setName("Trip to Rome"); + ExpenseHistoryEntry entry = historyEntry(100L, list, null, + HistoryEntryType.CREATED, "Taxi", java.time.LocalDateTime.of(2026, 7, 6, 12, 0)); + + when(historyRepository + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + eq(1L), eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entry))); + + List result = service.getRecentForUser(alice, 4); + + assertThat(result.get(0).actorUsername()).isNull(); + } + + @Test + void getRecentForUser_clampsLimitBetween1And20() { + AppUser alice = user(1L, "alice"); + when(historyRepository + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + eq(1L), eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + service.getRecentForUser(alice, 500); + verify(historyRepository) + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + 1L, 1L, PageRequest.of(0, 20)); + + service.getRecentForUser(alice, 0); + verify(historyRepository) + .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc( + 1L, 1L, PageRequest.of(0, 1)); + } } From af648bca701dac7240ec2813ed8f5d2ded5a3d10 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 19:04:40 +0200 Subject: [PATCH 10/11] feat: add GET /history/recent endpoint for cross-list activity --- .../controller/ExpenseListController.java | 9 ++++ .../controller/ExpenseListControllerTest.java | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+) 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 d075ddd..13b6606 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 @@ -15,6 +15,7 @@ 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.model.DTO.RecentHistoryEntryDto; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; @@ -194,6 +195,14 @@ public class ExpenseListController { return ResponseEntity.ok(historyService.getHistory(id, page, size)); } + @GetMapping("/history/recent") + public ResponseEntity> getRecentHistory( + @RequestParam(defaultValue = "4") int limit, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + return ResponseEntity.ok(historyService.getRecentForUser(user, limit)); + } + private void assertOwner(AppUser authenticated, ExpenseList list) { if (!list.getOwner().getId().equals(authenticated.getId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); 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 aba6b13..a4e0210 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 @@ -3,6 +3,7 @@ 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.DTO.RecentHistoryEntryDto; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; @@ -370,4 +371,49 @@ class ExpenseListControllerTest { mockMvc.perform(get("/api/expenselist/99/history")) .andExpect(status().isNotFound()); } + + // --- Recent history endpoint --- + + @Test + void getRecentHistory_returnsArrayWithListInfo() throws Exception { + AppUser user = new AppUser(); user.setId(1L); user.setUsername("alice"); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(historyService.getRecentForUser(user, 4)).thenReturn(List.of( + new RecentHistoryEntryDto("UPDATED", "alice", "Groceries", + LocalDateTime.of(2026, 7, 6, 12, 0), 10L, "Trip to Rome"))); + + mockMvc.perform(get("/api/expenselist/history/recent")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].type").value("UPDATED")) + .andExpect(jsonPath("$[0].actorUsername").value("alice")) + .andExpect(jsonPath("$[0].expenseTitle").value("Groceries")) + .andExpect(jsonPath("$[0].listId").value(10)) + .andExpect(jsonPath("$[0].listName").value("Trip to Rome")); + } + + @Test + void getRecentHistory_passesLimitParam() throws Exception { + AppUser user = new AppUser(); user.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(historyService.getRecentForUser(user, 10)).thenReturn(List.of()); + + mockMvc.perform(get("/api/expenselist/history/recent").param("limit", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void getRecentHistory_doesNotClashWithPerListHistoryRoute() throws Exception { + // /api/expenselist/1/history must still hit the per-list handler. + 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()); + } } From 7fd7882188a9eee5d4f47de40c80c6a1074828d6 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 7 Jul 2026 00:29:19 +0200 Subject: [PATCH 11/11] version upgrade --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 9d421c4..8482c04 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ de.zendric.app XpenselyServer - 1.1.0 + 1.2.0 XpenselyServer XpenselyServer used to handle the Xpensely App