feat: HistoryService with field-level diff logic
This commit is contained in:
@@ -72,6 +72,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -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<FieldChange> 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<FieldChange> diff(Expense before, Expense after) {
|
||||
List<FieldChange> 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<FieldChange> 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FieldChange> 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<FieldChange> 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<ExpenseHistoryEntry> 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<ExpenseHistoryEntry> 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<ExpenseHistoryEntry> captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
|
||||
verify(historyRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
|
||||
assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user