Dev #18

Merged
Cedric merged 11 commits from dev into main 2026-07-07 00:35:22 +02:00
3 changed files with 246 additions and 0 deletions
Showing only changes of commit 8f42add0c2 - Show all commits
+4
View File
@@ -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");
}
}