Compare commits
11 Commits
ac0118d435
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b1d96fad0 | |||
| b8938d521f | |||
| 4b6198ab5c | |||
| 9eb9483162 | |||
| 693289525e | |||
| a3d66129ac | |||
| da0cf48dc3 | |||
| 7fd7882188 | |||
| af648bca70 | |||
| b7e0fa1c8b | |||
| b58008c8bc |
@@ -10,7 +10,7 @@
|
||||
</parent>
|
||||
<groupId>de.zendric.app</groupId>
|
||||
<artifactId>XpenselyServer</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<version>1.2.2</version>
|
||||
<name>XpenselyServer</name>
|
||||
<description>XpenselyServer used to handle the Xpensely App</description>
|
||||
<url/>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -97,6 +98,18 @@ public class ExpenseListController {
|
||||
return new ResponseEntity<>(renamed, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/default-split")
|
||||
public ResponseEntity<ExpenseList> updateDefaultSplit(@PathVariable("id") Long id,
|
||||
@RequestBody @Valid DefaultSplitRequest request, 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());
|
||||
ExpenseList updated = expenseListService.updateDefaultSplit(id, request.getPercentage());
|
||||
return new ResponseEntity<>(updated, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/add")
|
||||
public ResponseEntity<Expense> addExpenseToList(
|
||||
@PathVariable("id") Long expenseListId,
|
||||
@@ -194,6 +207,14 @@ public class ExpenseListController {
|
||||
return ResponseEntity.ok(historyService.getHistory(id, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/history/recent")
|
||||
public ResponseEntity<List<RecentHistoryEntryDto>> 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);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.zendric.app.xpensely_server.model;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class DefaultSplitRequest {
|
||||
|
||||
@NotNull(message = "Percentage is required")
|
||||
@Min(value = 0, message = "Percentage must be between 0 and 100")
|
||||
@Max(value = 100, message = "Percentage must be between 0 and 100")
|
||||
private Integer percentage;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import java.time.LocalDateTime;
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -36,6 +38,12 @@ public class Expense {
|
||||
private Double personalUseAmount;
|
||||
private Double otherPersonAmount;
|
||||
private String category;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private SplitMode splitMode;
|
||||
|
||||
private Integer splitPercentage;
|
||||
|
||||
private LocalDate date;
|
||||
private LocalDateTime lastModified;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
@@ -24,17 +26,24 @@ 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;
|
||||
private Double otherPersonAmount;
|
||||
|
||||
private SplitMode splitMode;
|
||||
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
private Integer splitPercentage;
|
||||
|
||||
@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 +60,11 @@ 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);
|
||||
expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode);
|
||||
expense.setSplitPercentage(splitPercentage);
|
||||
|
||||
return expense;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
@@ -33,6 +35,12 @@ public class ExpenseInput {
|
||||
private Double personalUseAmount;
|
||||
private Double otherPersonAmount;
|
||||
|
||||
private SplitMode splitMode;
|
||||
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
private Integer splitPercentage;
|
||||
|
||||
@NotNull(message = "Date is required")
|
||||
private LocalDate date;
|
||||
|
||||
@@ -56,6 +64,8 @@ public class ExpenseInput {
|
||||
expense.setOwner(appUser);
|
||||
expense.setTitle(title);
|
||||
expense.setCategory(category);
|
||||
expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode);
|
||||
expense.setSplitPercentage(splitPercentage);
|
||||
|
||||
return expense;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public class ExpenseList {
|
||||
|
||||
private String name;
|
||||
|
||||
private Integer defaultSplitPercentage = 50;
|
||||
|
||||
private String inviteCode;
|
||||
@JsonIgnore
|
||||
private LocalDateTime inviteCodeExpiration;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.zendric.app.xpensely_server.model;
|
||||
|
||||
public enum SplitMode {
|
||||
DETAILED,
|
||||
PERCENTAGE
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public interface ExpenseHistoryRepository extends JpaRepository<ExpenseHistoryEn
|
||||
|
||||
Page<ExpenseHistoryEntry> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
|
||||
|
||||
Page<ExpenseHistoryEntry> findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
|
||||
Long ownerId, Long sharedWithId, Pageable pageable);
|
||||
|
||||
@Query("select h.expenseId from ExpenseHistoryEntry h")
|
||||
List<Long> findAllExpenseIds();
|
||||
|
||||
|
||||
@@ -50,6 +50,13 @@ public class ExpenseListService {
|
||||
return repository.save(list);
|
||||
}
|
||||
|
||||
public ExpenseList updateDefaultSplit(Long id, int percentage) {
|
||||
ExpenseList list = repository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + id));
|
||||
list.setDefaultSplitPercentage(percentage);
|
||||
return repository.save(list);
|
||||
}
|
||||
|
||||
public Optional<ExpenseList> findById(Long id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
@@ -137,6 +144,8 @@ public class ExpenseListService {
|
||||
existingExpense.setDate(updatedExpense.getDate());
|
||||
existingExpense.setOwner(updatedExpense.getOwner());
|
||||
existingExpense.setCategory(updatedExpense.getCategory());
|
||||
existingExpense.setSplitMode(updatedExpense.getSplitMode());
|
||||
existingExpense.setSplitPercentage(updatedExpense.getSplitPercentage());
|
||||
existingExpense.setLastModified(LocalDateTime.now());
|
||||
|
||||
Expense saved = expenseRepository.save(existingExpense);
|
||||
@@ -153,6 +162,8 @@ public class ExpenseListService {
|
||||
copy.setPersonalUseAmount(e.getPersonalUseAmount());
|
||||
copy.setOtherPersonAmount(e.getOtherPersonAmount());
|
||||
copy.setCategory(e.getCategory());
|
||||
copy.setSplitMode(e.getSplitMode());
|
||||
copy.setSplitPercentage(e.getSplitPercentage());
|
||||
copy.setDate(e.getDate());
|
||||
copy.setOwner(e.getOwner());
|
||||
return copy;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -68,6 +69,10 @@ public class HistoryService {
|
||||
addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount());
|
||||
addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount());
|
||||
addIfChanged(changes, "category", before.getCategory(), after.getCategory());
|
||||
addIfChanged(changes, "splitMode",
|
||||
before.getSplitMode() == null ? null : before.getSplitMode().name(),
|
||||
after.getSplitMode() == null ? null : after.getSplitMode().name());
|
||||
addIfChanged(changes, "splitPercentage", before.getSplitPercentage(), after.getSplitPercentage());
|
||||
addIfChanged(changes, "date", before.getDate(), after.getDate());
|
||||
addIfChanged(changes, "owner",
|
||||
before.getOwner() == null ? null : before.getOwner().getUsername(),
|
||||
@@ -104,6 +109,23 @@ public class HistoryService {
|
||||
return new HistoryPageDto(entries, result.hasNext());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecentHistoryEntryDto> getRecentForUser(AppUser user, int limit) {
|
||||
int cappedLimit = Math.min(Math.max(limit, 1), 20);
|
||||
Page<ExpenseHistoryEntry> 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<FieldChange> changes = List.of();
|
||||
if (entry.getChanges() != null) {
|
||||
|
||||
+128
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -53,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<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
|
||||
void addExpense_negativeAmount_returns400() throws Exception {
|
||||
mockMvc.perform(post("/api/expenselist/1/add")
|
||||
@@ -265,6 +314,40 @@ class ExpenseListControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// --- Default split ---
|
||||
|
||||
@Test
|
||||
void updateDefaultSplit_setsPercentageForMember() throws Exception {
|
||||
AppUser owner = new AppUser(); owner.setId(1L);
|
||||
ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner);
|
||||
|
||||
when(expenseListService.findById(5L)).thenReturn(Optional.of(list));
|
||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner);
|
||||
when(expenseListService.updateDefaultSplit(5L, 60)).thenReturn(list);
|
||||
|
||||
mockMvc.perform(put("/api/expenselist/5/default-split")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"percentage\":60}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.mockito.Mockito.verify(expenseListService).updateDefaultSplit(5L, 60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDefaultSplit_nonMember_returns403() throws Exception {
|
||||
AppUser owner = new AppUser(); owner.setId(1L);
|
||||
AppUser stranger = new AppUser(); stranger.setId(2L);
|
||||
ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner);
|
||||
|
||||
when(expenseListService.findById(5L)).thenReturn(Optional.of(list));
|
||||
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(stranger);
|
||||
|
||||
mockMvc.perform(put("/api/expenselist/5/default-split")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"percentage\":60}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// --- Duplicate-username invite guard ---
|
||||
|
||||
@Test
|
||||
@@ -322,4 +405,49 @@ class ExpenseListControllerTest {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.model.SplitMode;
|
||||
import de.zendric.app.xpensely_server.repo.ExpenseListRepository;
|
||||
import de.zendric.app.xpensely_server.repo.ExpenseRepository;
|
||||
import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository;
|
||||
@@ -109,6 +110,29 @@ class ExpenseListServiceTest {
|
||||
assertThat(beforeCaptor.getValue().getAmount()).isEqualTo(12.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateExpense_persistsSplitModeAndPercentage() {
|
||||
AppUser actor = new AppUser(); actor.setId(1L);
|
||||
ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(actor);
|
||||
Expense existing = makeExpense(5L, "Groceries", 100.0);
|
||||
list.setExpenses(new ArrayList<>(List.of(existing)));
|
||||
|
||||
Expense incoming = makeExpense(5L, "Groceries", 100.0);
|
||||
incoming.setDate(LocalDate.now());
|
||||
incoming.setOwner(list.getOwner());
|
||||
incoming.setSplitMode(SplitMode.PERCENTAGE);
|
||||
incoming.setSplitPercentage(70);
|
||||
|
||||
when(repository.findById(10L)).thenReturn(Optional.of(list));
|
||||
when(expenseRepository.findById(5L)).thenReturn(Optional.of(existing));
|
||||
when(expenseRepository.save(any(Expense.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Expense saved = service.updateExpense(10L, incoming, actor);
|
||||
|
||||
assertThat(saved.getSplitMode()).isEqualTo(SplitMode.PERCENTAGE);
|
||||
assertThat(saved.getSplitPercentage()).isEqualTo(70);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_deletesHistoryEntriesBeforeDeletingList() {
|
||||
service.deleteById(10L);
|
||||
|
||||
@@ -18,11 +18,13 @@ 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.model.SplitMode;
|
||||
import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository;
|
||||
import de.zendric.app.xpensely_server.services.HistoryService;
|
||||
|
||||
@@ -85,6 +87,22 @@ class HistoryServiceTest {
|
||||
assertThat(changes).containsExactly(new FieldChange("owner", "alice", "ben"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void diff_reportsSplitModeAndPercentageChanges() {
|
||||
Expense before = new Expense();
|
||||
before.setSplitMode(SplitMode.DETAILED);
|
||||
before.setSplitPercentage(null);
|
||||
|
||||
Expense after = new Expense();
|
||||
after.setSplitMode(SplitMode.PERCENTAGE);
|
||||
after.setSplitPercentage(70);
|
||||
|
||||
List<FieldChange> changes = service.diff(before, after);
|
||||
|
||||
assertThat(changes).contains(new FieldChange("splitMode", "DETAILED", "PERCENTAGE"));
|
||||
assertThat(changes).contains(new FieldChange("splitPercentage", null, "70"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void diff_nullToValue_isReported() {
|
||||
Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice"));
|
||||
@@ -206,4 +224,75 @@ class HistoryServiceTest {
|
||||
|
||||
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<RecentHistoryEntryDto> 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<RecentHistoryEntryDto> 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user