From 765c5d5b32abedcbdacc94c1976f7c6aecaa7509 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 12:50:33 +0200 Subject: [PATCH] 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()); + } +}