feat: idempotent startup backfill for lastModified and history
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user