Dev #18

Merged
Cedric merged 11 commits from dev into main 2026-07-07 00:35:22 +02:00
2 changed files with 153 additions and 0 deletions
Showing only changes of commit 765c5d5b32 - Show all commits
@@ -0,0 +1,61 @@
package de.zendric.app.xpensely_server.preparation;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.HistoryEntryType;
import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
/**
* One-time (idempotent) backfill for the history feature: fills
* Expense.lastModified from the expense date and creates a synthetic CREATED
* history entry for every expense that predates change logging. Both steps
* are no-ops on every startup after the first.
*/
@Component
public class HistoryBackfillRunner implements CommandLineRunner {
private final ExpenseRepository expenseRepository;
private final ExpenseHistoryRepository historyRepository;
public HistoryBackfillRunner(ExpenseRepository expenseRepository,
ExpenseHistoryRepository historyRepository) {
this.expenseRepository = expenseRepository;
this.historyRepository = historyRepository;
}
@Override
@Transactional
public void run(String... args) {
for (Expense expense : expenseRepository.findByLastModifiedIsNull()) {
expense.setLastModified(startOfDayOrNow(expense));
expenseRepository.save(expense);
}
Set<Long> loggedExpenseIds = new HashSet<>(historyRepository.findAllExpenseIds());
for (Expense expense : expenseRepository.findAll()) {
if (loggedExpenseIds.contains(expense.getId()))
continue;
ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
entry.setExpenseList(expense.getExpenseList());
entry.setExpenseId(expense.getId());
entry.setExpenseTitle(expense.getTitle());
entry.setActor(expense.getOwner());
entry.setType(HistoryEntryType.CREATED);
entry.setTimestamp(startOfDayOrNow(expense));
historyRepository.save(entry);
}
}
private LocalDateTime startOfDayOrNow(Expense expense) {
return expense.getDate() == null ? LocalDateTime.now() : expense.getDate().atStartOfDay();
}
}
@@ -0,0 +1,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());
}
}