diff --git a/pom.xml b/pom.xml
index 9d421c4..8482c04 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
de.zendric.app
XpenselyServer
- 1.1.0
+ 1.2.0
XpenselyServer
XpenselyServer used to handle the Xpensely App
diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
index 03ce5a9..13b6606 100644
--- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
+++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
@@ -14,9 +14,12 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import de.zendric.app.xpensely_server.model.*;
+import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
+import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.CategoryService;
import de.zendric.app.xpensely_server.services.ExpenseListService;
+import de.zendric.app.xpensely_server.services.HistoryService;
@RestController
@RequestMapping("/api/expenselist")
@@ -27,12 +30,15 @@ public class ExpenseListController {
private final ExpenseListService expenseListService;
private final CategoryService categoryService;
private final AuthenticatedUserResolver authenticatedUserResolver;
+ private final HistoryService historyService;
public ExpenseListController(ExpenseListService expenseListService,
- CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
+ CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver,
+ HistoryService historyService) {
this.expenseListService = expenseListService;
this.categoryService = categoryService;
this.authenticatedUserResolver = authenticatedUserResolver;
+ this.historyService = historyService;
}
@GetMapping("/mine")
@@ -104,7 +110,7 @@ public class ExpenseListController {
assertMember(user, listOpt.get());
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
- Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
+ Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense, user);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
}
@@ -120,7 +126,7 @@ public class ExpenseListController {
assertMember(user, expenseListOpt.get());
AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName());
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
- Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
+ Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense, user);
return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
}
@@ -134,7 +140,7 @@ public class ExpenseListController {
if (listOpt.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
assertMember(user, listOpt.get());
- expenseListService.deleteExpenseFromList(expenseListId, expenseId);
+ expenseListService.deleteExpenseFromList(expenseListId, expenseId, user);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@@ -175,6 +181,28 @@ public class ExpenseListController {
return ResponseEntity.ok("User added to the list");
}
+ @GetMapping("/{id}/history")
+ public ResponseEntity getHistory(
+ @PathVariable("id") Long id,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "30") int size,
+ Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional listOpt = expenseListService.findById(id);
+ if (listOpt.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ assertMember(user, listOpt.get());
+ return ResponseEntity.ok(historyService.getHistory(id, page, size));
+ }
+
+ @GetMapping("/history/recent")
+ public ResponseEntity> getRecentHistory(
+ @RequestParam(defaultValue = "4") int limit,
+ Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ return ResponseEntity.ok(historyService.getRecentForUser(user, limit));
+ }
+
private void assertOwner(AppUser authenticated, ExpenseList list) {
if (!list.getOwner().getId().equals(authenticated.getId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java
new file mode 100644
index 0000000..b598d9c
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryEntryDto.java
@@ -0,0 +1,14 @@
+package de.zendric.app.xpensely_server.model.DTO;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import de.zendric.app.xpensely_server.model.FieldChange;
+
+public record HistoryEntryDto(
+ String type,
+ String actorUsername,
+ String expenseTitle,
+ LocalDateTime timestamp,
+ List changes) {
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java
new file mode 100644
index 0000000..76b8b81
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/HistoryPageDto.java
@@ -0,0 +1,6 @@
+package de.zendric.app.xpensely_server.model.DTO;
+
+import java.util.List;
+
+public record HistoryPageDto(List entries, boolean hasMore) {
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java b/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java
new file mode 100644
index 0000000..6faccfd
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/DTO/RecentHistoryEntryDto.java
@@ -0,0 +1,12 @@
+package de.zendric.app.xpensely_server.model.DTO;
+
+import java.time.LocalDateTime;
+
+public record RecentHistoryEntryDto(
+ String type,
+ String actorUsername,
+ String expenseTitle,
+ LocalDateTime timestamp,
+ Long listId,
+ String listName) {
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java
index 9e97e24..34bc45f 100644
--- a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java
+++ b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java
@@ -1,6 +1,7 @@
package de.zendric.app.xpensely_server.model;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonBackReference;
@@ -36,6 +37,7 @@ public class Expense {
private Double otherPersonAmount;
private String category;
private LocalDate date;
+ private LocalDateTime lastModified;
@ManyToOne
@JoinColumn(name = "expense_list_id", nullable = false)
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java
index 20c161b..561ff11 100644
--- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java
+++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java
@@ -24,8 +24,10 @@ public class ExpenseChangeRequest {
@NotBlank(message = "Owner name is required")
private String ownerName;
+ // Same bounds as ExpenseInput: Quick Add creates stub expenses with amount 0
+ // and no category, and those stubs must remain editable.
@NotNull(message = "Amount is required")
- @DecimalMin(value = "0.01", message = "Amount must be greater than zero")
+ @DecimalMin(value = "0.00", message = "Amount must not be negative")
private Double amount;
private Double personalUseAmount;
@@ -34,7 +36,6 @@ public class ExpenseChangeRequest {
@NotNull(message = "Date is required")
private LocalDate date;
- @NotBlank(message = "Category is required")
private String category;
public Expense convertToExpense(Long userId, ExpenseList expenseList) {
@@ -51,7 +52,9 @@ public class ExpenseChangeRequest {
expense.setId(id);
expense.setOwner(appUser);
expense.setTitle(title);
- expense.setCategory(category);
+ // The edit dialog sends "" for a missing category; store null so the
+ // stub state stays uniform and history diffs don't report null -> "".
+ expense.setCategory(category == null || category.isBlank() ? null : category);
return expense;
}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java
new file mode 100644
index 0000000..5e74989
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseHistoryEntry.java
@@ -0,0 +1,52 @@
+package de.zendric.app.xpensely_server.model;
+
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@Entity
+@Table(name = "expense_history_entry", indexes = @Index(name = "idx_history_list_timestamp", columnList = "expense_list_id, timestamp DESC"))
+public class ExpenseHistoryEntry {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne
+ @JoinColumn(name = "expense_list_id", nullable = false)
+ private ExpenseList expenseList;
+
+ /** Plain column on purpose — no FK, so history survives expense deletion. */
+ private Long expenseId;
+
+ /** Denormalized so entries for deleted expenses still render. */
+ private String expenseTitle;
+
+ @ManyToOne
+ private AppUser actor;
+
+ @Enumerated(EnumType.STRING)
+ private HistoryEntryType type;
+
+ private LocalDateTime timestamp;
+
+ /** JSON array of FieldChange; null for CREATED/DELETED. */
+ @Column(columnDefinition = "TEXT")
+ private String changes;
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java b/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java
new file mode 100644
index 0000000..8976242
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/FieldChange.java
@@ -0,0 +1,9 @@
+package de.zendric.app.xpensely_server.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record FieldChange(
+ String field,
+ @JsonProperty("old") String oldValue,
+ @JsonProperty("new") String newValue) {
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java b/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java
new file mode 100644
index 0000000..a47f993
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/model/HistoryEntryType.java
@@ -0,0 +1,5 @@
+package de.zendric.app.xpensely_server.model;
+
+public enum HistoryEntryType {
+ CREATED, UPDATED, DELETED
+}
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/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java
new file mode 100644
index 0000000..e6c5f6d
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseHistoryRepository.java
@@ -0,0 +1,23 @@
+package de.zendric.app.xpensely_server.repo;
+
+import java.util.List;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
+
+public interface ExpenseHistoryRepository extends JpaRepository {
+
+ Page findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
+
+ Page findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ Long ownerId, Long sharedWithId, Pageable pageable);
+
+ @Query("select h.expenseId from ExpenseHistoryEntry h")
+ List findAllExpenseIds();
+
+ void deleteByExpenseListId(Long expenseListId);
+}
diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java
index fe7a610..659d240 100644
--- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java
+++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseRepository.java
@@ -10,4 +10,6 @@ import de.zendric.app.xpensely_server.model.Expense;
@Repository
public interface ExpenseRepository extends JpaRepository {
List findAllByOrderByDateAsc();
+
+ List findByLastModifiedIsNull();
}
diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java
index 7ab97f7..57756e7 100644
--- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java
+++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java
@@ -8,6 +8,7 @@ import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
@@ -23,12 +24,14 @@ public class ExpenseListService {
private final ExpenseListRepository repository;
private final ExpenseRepository expenseRepository;
private final XpenselyCustomCategoryRepository customCategoryRepository;
+ private final HistoryService historyService;
public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository,
- XpenselyCustomCategoryRepository customCategoryRepository) {
+ XpenselyCustomCategoryRepository customCategoryRepository, HistoryService historyService) {
this.repository = repository;
this.expenseRepository = expenseRepository;
this.customCategoryRepository = customCategoryRepository;
+ this.historyService = historyService;
}
public ExpenseList createList(ExpenseList list) {
@@ -36,6 +39,7 @@ public class ExpenseListService {
}
public void deleteById(Long id) {
+ historyService.deleteForList(id);
repository.deleteById(id);
}
@@ -62,15 +66,17 @@ public class ExpenseListService {
return repository.findByOwnerUsernameOrSharedWithUsername(username);
}
- public Expense addExpenseToList(Long expenseListId, Expense expense) {
+ public Expense addExpenseToList(Long expenseListId, Expense expense, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
+ expense.setLastModified(LocalDateTime.now());
expenseList.addExpense(expense);
repository.save(expenseList);
+ historyService.recordCreated(expenseList, expense, actor);
return expense;
}
- public void deleteExpenseFromList(Long expenseListId, Long expenseId) {
+ public void deleteExpenseFromList(Long expenseListId, Long expenseId, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
Expense expenseToRemove = null;
@@ -80,11 +86,11 @@ public class ExpenseListService {
break;
}
}
- if (expenseToRemove != null) {
- expenseList.removeExpense(expenseToRemove);
- } else {
+ if (expenseToRemove == null) {
throw new ResourceNotFoundException("Expense not found with id: " + expenseId);
}
+ historyService.recordDeleted(expenseList, expenseToRemove, actor);
+ expenseList.removeExpense(expenseToRemove);
repository.save(expenseList);
}
@@ -110,7 +116,7 @@ public class ExpenseListService {
return repository.findByInviteCode(inviteCode);
}
- public Expense updateExpense(Long expenseListId, Expense updatedExpense) {
+ public Expense updateExpense(Long expenseListId, Expense updatedExpense, AppUser actor) {
ExpenseList expenseList = repository.findById(expenseListId)
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
@@ -121,6 +127,9 @@ public class ExpenseListService {
Expense existingExpense = expenseRepository.findById(updatedExpense.getId())
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId()));
+
+ Expense before = snapshot(existingExpense);
+
existingExpense.setTitle(updatedExpense.getTitle());
existingExpense.setAmount(updatedExpense.getAmount());
existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount());
@@ -128,8 +137,25 @@ public class ExpenseListService {
existingExpense.setDate(updatedExpense.getDate());
existingExpense.setOwner(updatedExpense.getOwner());
existingExpense.setCategory(updatedExpense.getCategory());
+ existingExpense.setLastModified(LocalDateTime.now());
- return expenseRepository.save(existingExpense);
+ Expense saved = expenseRepository.save(existingExpense);
+ historyService.recordUpdated(expenseList, before, saved, actor);
+ return saved;
+ }
+
+ /** Detached copy of the mutable fields, taken before applying an update. */
+ private Expense snapshot(Expense e) {
+ Expense copy = new Expense();
+ copy.setId(e.getId());
+ copy.setTitle(e.getTitle());
+ copy.setAmount(e.getAmount());
+ copy.setPersonalUseAmount(e.getPersonalUseAmount());
+ copy.setOtherPersonAmount(e.getOtherPersonAmount());
+ copy.setCategory(e.getCategory());
+ copy.setDate(e.getDate());
+ copy.setOwner(e.getOwner());
+ return copy;
}
// TODO implement API for this
diff --git a/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java
new file mode 100644
index 0000000..6ea8ebf
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java
@@ -0,0 +1,143 @@
+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.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.ObjectMapper;
+
+import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.DTO.HistoryEntryDto;
+import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
+import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
+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 changes = diff(before, after);
+ if (changes.isEmpty())
+ return;
+ ExpenseHistoryEntry entry = baseEntry(list, after, actor, HistoryEntryType.UPDATED);
+ try {
+ entry.setChanges(objectMapper.writeValueAsString(changes));
+ } catch (JacksonException 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 void deleteForList(Long listId) {
+ historyRepository.deleteByExpenseListId(listId);
+ }
+
+ public List diff(Expense before, Expense after) {
+ List 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 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)));
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public HistoryPageDto getHistory(Long listId, int page, int size) {
+ int cappedSize = Math.min(Math.max(size, 1), 100);
+ Page result = historyRepository
+ .findByExpenseListIdOrderByTimestampDescIdDesc(listId,
+ PageRequest.of(Math.max(page, 0), cappedSize));
+ List entries = result.getContent().stream().map(this::toDto).toList();
+ return new HistoryPageDto(entries, result.hasNext());
+ }
+
+ @Transactional(readOnly = true)
+ public List getRecentForUser(AppUser user, int limit) {
+ int cappedLimit = Math.min(Math.max(limit, 1), 20);
+ Page result = historyRepository
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ user.getId(), user.getId(), PageRequest.of(0, cappedLimit));
+ return result.getContent().stream()
+ .map(e -> new RecentHistoryEntryDto(
+ e.getType().name(),
+ e.getActor() == null ? null : e.getActor().getUsername(),
+ e.getExpenseTitle(),
+ e.getTimestamp(),
+ e.getExpenseList().getId(),
+ e.getExpenseList().getName()))
+ .toList();
+ }
+
+ private HistoryEntryDto toDto(ExpenseHistoryEntry entry) {
+ List changes = List.of();
+ if (entry.getChanges() != null) {
+ try {
+ changes = objectMapper.readValue(entry.getChanges(),
+ new TypeReference>() {
+ });
+ } catch (JacksonException e) {
+ throw new IllegalStateException("Corrupt history changes for entry " + entry.getId(), e);
+ }
+ }
+ return new HistoryEntryDto(
+ entry.getType().name(),
+ entry.getActor() == null ? null : entry.getActor().getUsername(),
+ entry.getExpenseTitle(),
+ entry.getTimestamp(),
+ changes);
+ }
+}
diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java
index e203b64..a4e0210 100644
--- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java
+++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java
@@ -2,11 +2,14 @@ package de.zendric.app.xpensely_Server.controller;
import de.zendric.app.xpensely_server.controller.ExpenseListController;
import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
+import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.CategoryService;
import de.zendric.app.xpensely_server.services.ExpenseListService;
+import de.zendric.app.xpensely_server.services.HistoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
@@ -38,6 +41,7 @@ class ExpenseListControllerTest {
@MockitoBean ExpenseListService expenseListService;
@MockitoBean CategoryService categoryService;
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
+ @MockitoBean HistoryService historyService;
// --- Validation tests ---
@@ -50,6 +54,54 @@ class ExpenseListControllerTest {
.andExpect(jsonPath("$.title").exists());
}
+ // Quick Add creates stub expenses (amount 0, no category); updating such a
+ // stub must be allowed so the user can fill in the details afterwards.
+ @Test
+ void updateExpense_quickAddStub_zeroAmountAndNoCategory_returns200() throws Exception {
+ AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
+ ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
+ when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
+ when(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner)))
+ .thenAnswer(inv -> inv.getArgument(1));
+
+ mockMvc.perform(put("/api/expenselist/1/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0,"
+ + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\"}"))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void updateExpense_blankCategory_isStoredAsNull() throws Exception {
+ AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
+ ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
+ when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
+ when(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner)))
+ .thenAnswer(inv -> inv.getArgument(1));
+
+ mockMvc.perform(put("/api/expenselist/1/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0,"
+ + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"\"}"))
+ .andExpect(status().isOk());
+
+ org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Expense.class);
+ org.mockito.Mockito.verify(expenseListService).updateExpense(eq(1L), captor.capture(), eq(owner));
+ org.assertj.core.api.Assertions.assertThat(captor.getValue().getCategory()).isNull();
+ }
+
+ @Test
+ void updateExpense_negativeAmount_returns400() throws Exception {
+ mockMvc.perform(put("/api/expenselist/1/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":-5.0,"
+ + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"Food\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.amount").exists());
+ }
+
@Test
void addExpense_negativeAmount_returns400() throws Exception {
mockMvc.perform(post("/api/expenselist/1/add")
@@ -199,7 +251,7 @@ class ExpenseListControllerTest {
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
- when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense());
+ when(expenseListService.addExpenseToList(eq(1L), any(), any())).thenReturn(new Expense());
mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON)
@@ -281,4 +333,87 @@ class ExpenseListControllerTest {
.content("{\"inviteCode\":\"ABC123\"}"))
.andExpect(status().isConflict());
}
+
+ // --- History endpoint ---
+
+ @Test
+ void getHistory_member_returns200WithPage() throws Exception {
+ AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
+ ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
+ when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
+ when(historyService.getHistory(1L, 0, 30))
+ .thenReturn(new HistoryPageDto(List.of(), false));
+
+ mockMvc.perform(get("/api/expenselist/1/history"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.entries").isArray())
+ .andExpect(jsonPath("$.hasMore").value(false));
+ }
+
+ @Test
+ void getHistory_nonMember_returns403() throws Exception {
+ AppUser owner = new AppUser(); owner.setId(1L);
+ AppUser stranger = new AppUser(); stranger.setId(2L);
+ ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
+ when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(stranger);
+
+ mockMvc.perform(get("/api/expenselist/1/history"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void getHistory_unknownList_returns404() throws Exception {
+ when(expenseListService.findById(99L)).thenReturn(Optional.empty());
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(new AppUser());
+
+ mockMvc.perform(get("/api/expenselist/99/history"))
+ .andExpect(status().isNotFound());
+ }
+
+ // --- Recent history endpoint ---
+
+ @Test
+ void getRecentHistory_returnsArrayWithListInfo() throws Exception {
+ AppUser user = new AppUser(); user.setId(1L); user.setUsername("alice");
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user);
+ when(historyService.getRecentForUser(user, 4)).thenReturn(List.of(
+ new RecentHistoryEntryDto("UPDATED", "alice", "Groceries",
+ LocalDateTime.of(2026, 7, 6, 12, 0), 10L, "Trip to Rome")));
+
+ mockMvc.perform(get("/api/expenselist/history/recent"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].type").value("UPDATED"))
+ .andExpect(jsonPath("$[0].actorUsername").value("alice"))
+ .andExpect(jsonPath("$[0].expenseTitle").value("Groceries"))
+ .andExpect(jsonPath("$[0].listId").value(10))
+ .andExpect(jsonPath("$[0].listName").value("Trip to Rome"));
+ }
+
+ @Test
+ void getRecentHistory_passesLimitParam() throws Exception {
+ AppUser user = new AppUser(); user.setId(1L);
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user);
+ when(historyService.getRecentForUser(user, 10)).thenReturn(List.of());
+
+ mockMvc.perform(get("/api/expenselist/history/recent").param("limit", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$").isEmpty());
+ }
+
+ @Test
+ void getRecentHistory_doesNotClashWithPerListHistoryRoute() throws Exception {
+ // /api/expenselist/1/history must still hit the per-list handler.
+ AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice");
+ ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner);
+ when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
+ when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
+ when(historyService.getHistory(1L, 0, 30))
+ .thenReturn(new HistoryPageDto(List.of(), false));
+
+ mockMvc.perform(get("/api/expenselist/1/history"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.entries").isArray());
+ }
}
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());
+ }
+}
diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java
index 0f832a5..7c30ef6 100644
--- a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java
+++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java
@@ -1,20 +1,27 @@
package de.zendric.app.xpensely_Server.services;
import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.repo.ExpenseListRepository;
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository;
import de.zendric.app.xpensely_server.services.ExpenseListService;
+import de.zendric.app.xpensely_server.services.HistoryService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
@@ -25,6 +32,7 @@ class ExpenseListServiceTest {
@Mock ExpenseListRepository repository;
@Mock ExpenseRepository expenseRepository;
@Mock XpenselyCustomCategoryRepository customCategoryRepository;
+ @Mock HistoryService historyService;
@InjectMocks
ExpenseListService service;
@@ -54,4 +62,72 @@ class ExpenseListServiceTest {
verify(repository).findByOwnerUsernameOrSharedWithUsername("alice");
verify(repository, never()).findAll();
}
+
+ private Expense makeExpense(Long id, String title, double amount) {
+ Expense e = new Expense();
+ e.setId(id);
+ e.setTitle(title);
+ e.setAmount(amount);
+ e.setPersonalUseAmount(0.0);
+ e.setOtherPersonAmount(0.0);
+ e.setDate(LocalDate.of(2026, 7, 1));
+ return e;
+ }
+
+ @Test
+ void addExpenseToList_setsLastModified_andRecordsCreated() {
+ AppUser actor = new AppUser(); actor.setId(1L);
+ ExpenseList list = new ExpenseList(); list.setId(10L);
+ list.setExpenses(new ArrayList<>());
+ when(repository.findById(10L)).thenReturn(Optional.of(list));
+
+ Expense expense = makeExpense(null, "Lunch", 10.0);
+ service.addExpenseToList(10L, expense, actor);
+
+ assertThat(expense.getLastModified()).isNotNull();
+ verify(historyService).recordCreated(list, expense, actor);
+ }
+
+ @Test
+ void updateExpense_setsLastModified_andRecordsUpdatedWithBeforeSnapshot() {
+ AppUser actor = new AppUser(); actor.setId(1L);
+ ExpenseList list = new ExpenseList(); list.setId(10L);
+ Expense existing = makeExpense(42L, "Old title", 12.0);
+ list.setExpenses(new ArrayList<>(List.of(existing)));
+ when(repository.findById(10L)).thenReturn(Optional.of(list));
+ when(expenseRepository.findById(42L)).thenReturn(Optional.of(existing));
+ when(expenseRepository.save(any(Expense.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ Expense updated = makeExpense(42L, "New title", 15.0);
+ service.updateExpense(10L, updated, actor);
+
+ assertThat(existing.getLastModified()).isNotNull();
+ org.mockito.ArgumentCaptor beforeCaptor =
+ org.mockito.ArgumentCaptor.forClass(Expense.class);
+ verify(historyService).recordUpdated(eq(list), beforeCaptor.capture(), eq(existing), eq(actor));
+ assertThat(beforeCaptor.getValue().getTitle()).isEqualTo("Old title");
+ assertThat(beforeCaptor.getValue().getAmount()).isEqualTo(12.0);
+ }
+
+ @Test
+ void deleteById_deletesHistoryEntriesBeforeDeletingList() {
+ service.deleteById(10L);
+
+ org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(historyService, repository);
+ inOrder.verify(historyService).deleteForList(10L);
+ inOrder.verify(repository).deleteById(10L);
+ }
+
+ @Test
+ void deleteExpenseFromList_recordsDeleted() {
+ AppUser actor = new AppUser(); actor.setId(1L);
+ ExpenseList list = new ExpenseList(); list.setId(10L);
+ Expense existing = makeExpense(42L, "Taxi", 9.0);
+ list.setExpenses(new ArrayList<>(List.of(existing)));
+ when(repository.findById(10L)).thenReturn(Optional.of(list));
+
+ service.deleteExpenseFromList(10L, 42L, actor);
+
+ verify(historyService).recordDeleted(eq(list), any(Expense.class), eq(actor));
+ }
}
diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java
new file mode 100644
index 0000000..7301a35
--- /dev/null
+++ b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java
@@ -0,0 +1,281 @@
+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 org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import tools.jackson.databind.ObjectMapper;
+
+import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
+import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
+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.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@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 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 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 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 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 captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
+ verify(historyRepository).save(captor.capture());
+ assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
+ assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi");
+ }
+
+ @Test
+ void getHistory_mapsEntriesToDto_andParsesChangesJson() {
+ ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
+ entry.setType(HistoryEntryType.UPDATED);
+ entry.setActor(user(1L, "alice"));
+ entry.setExpenseTitle("Groceries");
+ entry.setTimestamp(java.time.LocalDateTime.of(2026, 7, 6, 14, 31));
+ entry.setChanges("[{\"field\":\"amount\",\"old\":\"12.0\",\"new\":\"15.5\"}]");
+ when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(entry), PageRequest.of(0, 30), 31));
+
+ HistoryPageDto page = service.getHistory(5L, 0, 30);
+
+ assertThat(page.hasMore()).isTrue();
+ assertThat(page.entries()).hasSize(1);
+ assertThat(page.entries().get(0).type()).isEqualTo("UPDATED");
+ assertThat(page.entries().get(0).actorUsername()).isEqualTo("alice");
+ assertThat(page.entries().get(0).changes())
+ .containsExactly(new FieldChange("amount", "12.0", "15.5"));
+ }
+
+ @Test
+ void getHistory_capsPageSizeAt100() {
+ when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of()));
+
+ service.getHistory(5L, 0, 5000);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class);
+ verify(historyRepository).findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), captor.capture());
+ assertThat(captor.getValue().getPageSize()).isEqualTo(100);
+ }
+
+ @Test
+ void getHistory_nullChanges_yieldsEmptyList() {
+ ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
+ entry.setType(HistoryEntryType.CREATED);
+ entry.setActor(user(1L, "alice"));
+ entry.setExpenseTitle("Groceries");
+ entry.setTimestamp(java.time.LocalDateTime.of(2026, 7, 6, 14, 31));
+ when(historyRepository.findByExpenseListIdOrderByTimestampDescIdDesc(eq(5L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(entry)));
+
+ HistoryPageDto page = service.getHistory(5L, 0, 30);
+
+ assertThat(page.entries().get(0).changes()).isEmpty();
+ }
+
+ private ExpenseHistoryEntry historyEntry(Long id, ExpenseList list, AppUser actor,
+ HistoryEntryType type, String title, java.time.LocalDateTime timestamp) {
+ ExpenseHistoryEntry e = new ExpenseHistoryEntry();
+ e.setId(id);
+ e.setExpenseList(list);
+ e.setActor(actor);
+ e.setType(type);
+ e.setExpenseTitle(title);
+ e.setTimestamp(timestamp);
+ return e;
+ }
+
+ @Test
+ void getRecentForUser_mapsEntriesWithListIdAndName() {
+ AppUser alice = user(1L, "alice");
+ ExpenseList list = new ExpenseList();
+ list.setId(10L);
+ list.setName("Trip to Rome");
+ ExpenseHistoryEntry entry = historyEntry(100L, list, alice,
+ HistoryEntryType.UPDATED, "Groceries", java.time.LocalDateTime.of(2026, 7, 6, 12, 0));
+
+ when(historyRepository
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ eq(1L), eq(1L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(entry)));
+
+ List result = service.getRecentForUser(alice, 4);
+
+ assertThat(result).containsExactly(new RecentHistoryEntryDto(
+ "UPDATED", "alice", "Groceries",
+ java.time.LocalDateTime.of(2026, 7, 6, 12, 0), 10L, "Trip to Rome"));
+ }
+
+ @Test
+ void getRecentForUser_nullActor_mapsToNullUsername() {
+ AppUser alice = user(1L, "alice");
+ ExpenseList list = new ExpenseList();
+ list.setId(10L);
+ list.setName("Trip to Rome");
+ ExpenseHistoryEntry entry = historyEntry(100L, list, null,
+ HistoryEntryType.CREATED, "Taxi", java.time.LocalDateTime.of(2026, 7, 6, 12, 0));
+
+ when(historyRepository
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ eq(1L), eq(1L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(entry)));
+
+ List result = service.getRecentForUser(alice, 4);
+
+ assertThat(result.get(0).actorUsername()).isNull();
+ }
+
+ @Test
+ void getRecentForUser_clampsLimitBetween1And20() {
+ AppUser alice = user(1L, "alice");
+ when(historyRepository
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ eq(1L), eq(1L), any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of()));
+
+ service.getRecentForUser(alice, 500);
+ verify(historyRepository)
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ 1L, 1L, PageRequest.of(0, 20));
+
+ service.getRecentForUser(alice, 0);
+ verify(historyRepository)
+ .findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
+ 1L, 1L, PageRequest.of(0, 1));
+ }
+}