From 8f42add0c202d7ffd6593d26415461cb8ba55dbe Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:27:55 +0200 Subject: [PATCH] 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"); + } +}