Compare commits
2 Commits
b58008c8bc
..
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 003cf7e2da | |||
| c1c530c46b |
@@ -14,11 +14,9 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.*;
|
import de.zendric.app.xpensely_server.model.*;
|
||||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
|
||||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||||
import de.zendric.app.xpensely_server.services.HistoryService;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/expenselist")
|
@RequestMapping("/api/expenselist")
|
||||||
@@ -29,15 +27,12 @@ public class ExpenseListController {
|
|||||||
private final ExpenseListService expenseListService;
|
private final ExpenseListService expenseListService;
|
||||||
private final CategoryService categoryService;
|
private final CategoryService categoryService;
|
||||||
private final AuthenticatedUserResolver authenticatedUserResolver;
|
private final AuthenticatedUserResolver authenticatedUserResolver;
|
||||||
private final HistoryService historyService;
|
|
||||||
|
|
||||||
public ExpenseListController(ExpenseListService expenseListService,
|
public ExpenseListController(ExpenseListService expenseListService,
|
||||||
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver,
|
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
|
||||||
HistoryService historyService) {
|
|
||||||
this.expenseListService = expenseListService;
|
this.expenseListService = expenseListService;
|
||||||
this.categoryService = categoryService;
|
this.categoryService = categoryService;
|
||||||
this.authenticatedUserResolver = authenticatedUserResolver;
|
this.authenticatedUserResolver = authenticatedUserResolver;
|
||||||
this.historyService = historyService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/mine")
|
@GetMapping("/mine")
|
||||||
@@ -109,7 +104,7 @@ public class ExpenseListController {
|
|||||||
assertMember(user, listOpt.get());
|
assertMember(user, listOpt.get());
|
||||||
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
|
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
|
||||||
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
|
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
|
||||||
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense, user);
|
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
|
||||||
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
|
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +120,7 @@ public class ExpenseListController {
|
|||||||
assertMember(user, expenseListOpt.get());
|
assertMember(user, expenseListOpt.get());
|
||||||
AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName());
|
AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName());
|
||||||
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
|
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
|
||||||
Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense, user);
|
Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
|
||||||
return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
|
return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +134,7 @@ public class ExpenseListController {
|
|||||||
if (listOpt.isEmpty())
|
if (listOpt.isEmpty())
|
||||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||||
assertMember(user, listOpt.get());
|
assertMember(user, listOpt.get());
|
||||||
expenseListService.deleteExpenseFromList(expenseListId, expenseId, user);
|
expenseListService.deleteExpenseFromList(expenseListId, expenseId);
|
||||||
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,20 +175,6 @@ public class ExpenseListController {
|
|||||||
return ResponseEntity.ok("User added to the list");
|
return ResponseEntity.ok("User added to the list");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/history")
|
|
||||||
public ResponseEntity<HistoryPageDto> getHistory(
|
|
||||||
@PathVariable("id") Long id,
|
|
||||||
@RequestParam(defaultValue = "0") int page,
|
|
||||||
@RequestParam(defaultValue = "30") int size,
|
|
||||||
Authentication authentication) {
|
|
||||||
AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
|
|
||||||
Optional<ExpenseList> 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assertOwner(AppUser authenticated, ExpenseList list) {
|
private void assertOwner(AppUser authenticated, ExpenseList list) {
|
||||||
if (!list.getOwner().getId().equals(authenticated.getId()))
|
if (!list.getOwner().getId().equals(authenticated.getId()))
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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<FieldChange> changes) {
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package de.zendric.app.xpensely_server.model.DTO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record HistoryPageDto(List<HistoryEntryDto> entries, boolean hasMore) {
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.zendric.app.xpensely_server.model;
|
package de.zendric.app.xpensely_server.model;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ public class Expense {
|
|||||||
private Double otherPersonAmount;
|
private Double otherPersonAmount;
|
||||||
private String category;
|
private String category;
|
||||||
private LocalDate date;
|
private LocalDate date;
|
||||||
private LocalDateTime lastModified;
|
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "expense_list_id", nullable = false)
|
@JoinColumn(name = "expense_list_id", nullable = false)
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ public class ExpenseChangeRequest {
|
|||||||
@NotBlank(message = "Owner name is required")
|
@NotBlank(message = "Owner name is required")
|
||||||
private String ownerName;
|
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")
|
@NotNull(message = "Amount is required")
|
||||||
@DecimalMin(value = "0.00", message = "Amount must not be negative")
|
@DecimalMin(value = "0.01", message = "Amount must be greater than zero")
|
||||||
private Double amount;
|
private Double amount;
|
||||||
|
|
||||||
private Double personalUseAmount;
|
private Double personalUseAmount;
|
||||||
@@ -36,6 +34,7 @@ public class ExpenseChangeRequest {
|
|||||||
@NotNull(message = "Date is required")
|
@NotNull(message = "Date is required")
|
||||||
private LocalDate date;
|
private LocalDate date;
|
||||||
|
|
||||||
|
@NotBlank(message = "Category is required")
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
public Expense convertToExpense(Long userId, ExpenseList expenseList) {
|
public Expense convertToExpense(Long userId, ExpenseList expenseList) {
|
||||||
@@ -52,9 +51,7 @@ public class ExpenseChangeRequest {
|
|||||||
expense.setId(id);
|
expense.setId(id);
|
||||||
expense.setOwner(appUser);
|
expense.setOwner(appUser);
|
||||||
expense.setTitle(title);
|
expense.setTitle(title);
|
||||||
// The edit dialog sends "" for a missing category; store null so the
|
expense.setCategory(category);
|
||||||
// stub state stays uniform and history diffs don't report null -> "".
|
|
||||||
expense.setCategory(category == null || category.isBlank() ? null : category);
|
|
||||||
|
|
||||||
return expense;
|
return expense;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package de.zendric.app.xpensely_server.model;
|
|
||||||
|
|
||||||
public enum HistoryEntryType {
|
|
||||||
CREATED, UPDATED, DELETED
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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<ExpenseHistoryEntry, Long> {
|
|
||||||
|
|
||||||
Page<ExpenseHistoryEntry> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
|
|
||||||
|
|
||||||
@Query("select h.expenseId from ExpenseHistoryEntry h")
|
|
||||||
List<Long> findAllExpenseIds();
|
|
||||||
|
|
||||||
void deleteByExpenseListId(Long expenseListId);
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,4 @@ import de.zendric.app.xpensely_server.model.Expense;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
|
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
|
||||||
List<Expense> findAllByOrderByDateAsc();
|
List<Expense> findAllByOrderByDateAsc();
|
||||||
|
|
||||||
List<Expense> findByLastModifiedIsNull();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import java.util.UUID;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.Expense;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||||
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
|
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
|
||||||
@@ -24,14 +23,12 @@ public class ExpenseListService {
|
|||||||
private final ExpenseListRepository repository;
|
private final ExpenseListRepository repository;
|
||||||
private final ExpenseRepository expenseRepository;
|
private final ExpenseRepository expenseRepository;
|
||||||
private final XpenselyCustomCategoryRepository customCategoryRepository;
|
private final XpenselyCustomCategoryRepository customCategoryRepository;
|
||||||
private final HistoryService historyService;
|
|
||||||
|
|
||||||
public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository,
|
public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository,
|
||||||
XpenselyCustomCategoryRepository customCategoryRepository, HistoryService historyService) {
|
XpenselyCustomCategoryRepository customCategoryRepository) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.expenseRepository = expenseRepository;
|
this.expenseRepository = expenseRepository;
|
||||||
this.customCategoryRepository = customCategoryRepository;
|
this.customCategoryRepository = customCategoryRepository;
|
||||||
this.historyService = historyService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ExpenseList createList(ExpenseList list) {
|
public ExpenseList createList(ExpenseList list) {
|
||||||
@@ -39,7 +36,6 @@ public class ExpenseListService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void deleteById(Long id) {
|
public void deleteById(Long id) {
|
||||||
historyService.deleteForList(id);
|
|
||||||
repository.deleteById(id);
|
repository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +62,15 @@ public class ExpenseListService {
|
|||||||
return repository.findByOwnerUsernameOrSharedWithUsername(username);
|
return repository.findByOwnerUsernameOrSharedWithUsername(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Expense addExpenseToList(Long expenseListId, Expense expense, AppUser actor) {
|
public Expense addExpenseToList(Long expenseListId, Expense expense) {
|
||||||
ExpenseList expenseList = repository.findById(expenseListId)
|
ExpenseList expenseList = repository.findById(expenseListId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
||||||
expense.setLastModified(LocalDateTime.now());
|
|
||||||
expenseList.addExpense(expense);
|
expenseList.addExpense(expense);
|
||||||
repository.save(expenseList);
|
repository.save(expenseList);
|
||||||
historyService.recordCreated(expenseList, expense, actor);
|
|
||||||
return expense;
|
return expense;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteExpenseFromList(Long expenseListId, Long expenseId, AppUser actor) {
|
public void deleteExpenseFromList(Long expenseListId, Long expenseId) {
|
||||||
ExpenseList expenseList = repository.findById(expenseListId)
|
ExpenseList expenseList = repository.findById(expenseListId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
||||||
Expense expenseToRemove = null;
|
Expense expenseToRemove = null;
|
||||||
@@ -86,11 +80,11 @@ public class ExpenseListService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (expenseToRemove == null) {
|
if (expenseToRemove != null) {
|
||||||
|
expenseList.removeExpense(expenseToRemove);
|
||||||
|
} else {
|
||||||
throw new ResourceNotFoundException("Expense not found with id: " + expenseId);
|
throw new ResourceNotFoundException("Expense not found with id: " + expenseId);
|
||||||
}
|
}
|
||||||
historyService.recordDeleted(expenseList, expenseToRemove, actor);
|
|
||||||
expenseList.removeExpense(expenseToRemove);
|
|
||||||
repository.save(expenseList);
|
repository.save(expenseList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +110,7 @@ public class ExpenseListService {
|
|||||||
return repository.findByInviteCode(inviteCode);
|
return repository.findByInviteCode(inviteCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Expense updateExpense(Long expenseListId, Expense updatedExpense, AppUser actor) {
|
public Expense updateExpense(Long expenseListId, Expense updatedExpense) {
|
||||||
ExpenseList expenseList = repository.findById(expenseListId)
|
ExpenseList expenseList = repository.findById(expenseListId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId));
|
||||||
|
|
||||||
@@ -127,9 +121,6 @@ public class ExpenseListService {
|
|||||||
|
|
||||||
Expense existingExpense = expenseRepository.findById(updatedExpense.getId())
|
Expense existingExpense = expenseRepository.findById(updatedExpense.getId())
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId()));
|
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId()));
|
||||||
|
|
||||||
Expense before = snapshot(existingExpense);
|
|
||||||
|
|
||||||
existingExpense.setTitle(updatedExpense.getTitle());
|
existingExpense.setTitle(updatedExpense.getTitle());
|
||||||
existingExpense.setAmount(updatedExpense.getAmount());
|
existingExpense.setAmount(updatedExpense.getAmount());
|
||||||
existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount());
|
existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount());
|
||||||
@@ -137,25 +128,8 @@ public class ExpenseListService {
|
|||||||
existingExpense.setDate(updatedExpense.getDate());
|
existingExpense.setDate(updatedExpense.getDate());
|
||||||
existingExpense.setOwner(updatedExpense.getOwner());
|
existingExpense.setOwner(updatedExpense.getOwner());
|
||||||
existingExpense.setCategory(updatedExpense.getCategory());
|
existingExpense.setCategory(updatedExpense.getCategory());
|
||||||
existingExpense.setLastModified(LocalDateTime.now());
|
|
||||||
|
|
||||||
Expense saved = expenseRepository.save(existingExpense);
|
return 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
|
// TODO implement API for this
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
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.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 (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<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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public HistoryPageDto getHistory(Long listId, int page, int size) {
|
|
||||||
int cappedSize = Math.min(Math.max(size, 1), 100);
|
|
||||||
Page<ExpenseHistoryEntry> result = historyRepository
|
|
||||||
.findByExpenseListIdOrderByTimestampDescIdDesc(listId,
|
|
||||||
PageRequest.of(Math.max(page, 0), cappedSize));
|
|
||||||
List<HistoryEntryDto> entries = result.getContent().stream().map(this::toDto).toList();
|
|
||||||
return new HistoryPageDto(entries, result.hasNext());
|
|
||||||
}
|
|
||||||
|
|
||||||
private HistoryEntryDto toDto(ExpenseHistoryEntry entry) {
|
|
||||||
List<FieldChange> changes = List.of();
|
|
||||||
if (entry.getChanges() != null) {
|
|
||||||
try {
|
|
||||||
changes = objectMapper.readValue(entry.getChanges(),
|
|
||||||
new TypeReference<List<FieldChange>>() {
|
|
||||||
});
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-90
@@ -2,13 +2,11 @@ package de.zendric.app.xpensely_Server.controller;
|
|||||||
|
|
||||||
import de.zendric.app.xpensely_server.controller.ExpenseListController;
|
import de.zendric.app.xpensely_server.controller.ExpenseListController;
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
import de.zendric.app.xpensely_server.model.AppUser;
|
||||||
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
|
|
||||||
import de.zendric.app.xpensely_server.model.Expense;
|
import de.zendric.app.xpensely_server.model.Expense;
|
||||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
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.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
|
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
|
||||||
@@ -40,7 +38,6 @@ class ExpenseListControllerTest {
|
|||||||
@MockitoBean ExpenseListService expenseListService;
|
@MockitoBean ExpenseListService expenseListService;
|
||||||
@MockitoBean CategoryService categoryService;
|
@MockitoBean CategoryService categoryService;
|
||||||
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
||||||
@MockitoBean HistoryService historyService;
|
|
||||||
|
|
||||||
// --- Validation tests ---
|
// --- Validation tests ---
|
||||||
|
|
||||||
@@ -53,54 +50,6 @@ class ExpenseListControllerTest {
|
|||||||
.andExpect(jsonPath("$.title").exists());
|
.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<Expense> 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
|
@Test
|
||||||
void addExpense_negativeAmount_returns400() throws Exception {
|
void addExpense_negativeAmount_returns400() throws Exception {
|
||||||
mockMvc.perform(post("/api/expenselist/1/add")
|
mockMvc.perform(post("/api/expenselist/1/add")
|
||||||
@@ -250,7 +199,7 @@ class ExpenseListControllerTest {
|
|||||||
|
|
||||||
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
|
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
|
||||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
|
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
|
||||||
when(expenseListService.addExpenseToList(eq(1L), any(), any())).thenReturn(new Expense());
|
when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/expenselist/1/add")
|
mockMvc.perform(post("/api/expenselist/1/add")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -332,42 +281,4 @@ class ExpenseListControllerTest {
|
|||||||
.content("{\"inviteCode\":\"ABC123\"}"))
|
.content("{\"inviteCode\":\"ABC123\"}"))
|
||||||
.andExpect(status().isConflict());
|
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,20 @@
|
|||||||
package de.zendric.app.xpensely_Server.services;
|
package de.zendric.app.xpensely_Server.services;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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.ExpenseList;
|
||||||
import de.zendric.app.xpensely_server.repo.ExpenseListRepository;
|
import de.zendric.app.xpensely_server.repo.ExpenseListRepository;
|
||||||
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
|
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
|
||||||
import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository;
|
import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository;
|
||||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
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.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -32,7 +25,6 @@ class ExpenseListServiceTest {
|
|||||||
@Mock ExpenseListRepository repository;
|
@Mock ExpenseListRepository repository;
|
||||||
@Mock ExpenseRepository expenseRepository;
|
@Mock ExpenseRepository expenseRepository;
|
||||||
@Mock XpenselyCustomCategoryRepository customCategoryRepository;
|
@Mock XpenselyCustomCategoryRepository customCategoryRepository;
|
||||||
@Mock HistoryService historyService;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
ExpenseListService service;
|
ExpenseListService service;
|
||||||
@@ -62,72 +54,4 @@ class ExpenseListServiceTest {
|
|||||||
verify(repository).findByOwnerUsernameOrSharedWithUsername("alice");
|
verify(repository).findByOwnerUsernameOrSharedWithUsername("alice");
|
||||||
verify(repository, never()).findAll();
|
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<Expense> 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
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.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<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");
|
|
||||||
}
|
|
||||||
|
|
||||||
@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<Pageable> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user