15 Commits

Author SHA1 Message Date
Cedric c9ba0477a0 Merge pull request 'Version Bump' (#20) from dev into main
Build and Deploy Versioned Spring Boot Server / build (push) Successful in 1m29s
Reviewed-on: #20
2026-07-08 13:16:57 +02:00
Cedric 8b1d96fad0 Version Bump
Build and Deploy Spring Boot Server / build (push) Successful in 1m16s
2026-07-08 13:16:24 +02:00
Cedric 4ce31a7774 Merge pull request 'Release 1.2.1' (#19) from dev into main
Reviewed-on: #19
2026-07-08 11:23:25 +02:00
Cedric b8938d521f Version 1.2.1
Build and Deploy Spring Boot Server / build (push) Successful in 1m14s
2026-07-08 10:38:09 +02:00
Cedric 4b6198ab5c Merge feature/split-modes: persist split modes + default-split endpoint
Build and Deploy Spring Boot Server / build (push) Successful in 1m17s
2026-07-08 09:18:48 +02:00
Cedric 9eb9483162 fix: validate splitPercentage bounds and cover default-split auth 2026-07-08 09:15:23 +02:00
Cedric 693289525e feat: add PUT /{id}/default-split endpoint 2026-07-07 22:03:24 +02:00
Cedric a3d66129ac feat: record split mode/percentage changes in history diff 2026-07-07 21:59:30 +02:00
Cedric da0cf48dc3 feat: persist splitMode/splitPercentage and list defaultSplitPercentage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:55:55 +02:00
Cedric 9a109b4709 Merge pull request 'Dev' (#18) from dev into main
Build and Deploy Versioned Spring Boot Server / build (push) Successful in 1m25s
Reviewed-on: #18
2026-07-07 00:35:22 +02:00
Cedric 7fd7882188 version upgrade
Build and Deploy Spring Boot Server / build (push) Successful in 1m9s
2026-07-07 00:29:19 +02:00
Cedric af648bca70 feat: add GET /history/recent endpoint for cross-list activity
Build and Deploy Spring Boot Server / build (push) Successful in 1m30s
2026-07-06 19:04:40 +02:00
Cedric b7e0fa1c8b feat: add cross-list recent history query and service method 2026-07-06 18:59:32 +02:00
Cedric 003cf7e2da Merge pull request 'Fix Tag Workflow' (#17) from dev into main
Build and Deploy Versioned Spring Boot Server / build (push) Successful in 1m16s
Reviewed-on: #17
2026-07-05 00:08:49 +02:00
Cedric c1c530c46b Merge pull request 'Release 1.1.0' (#16) from dev into main
Reviewed-on: #16
2026-07-05 00:03:46 +02:00
15 changed files with 316 additions and 1 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</parent> </parent>
<groupId>de.zendric.app</groupId> <groupId>de.zendric.app</groupId>
<artifactId>XpenselyServer</artifactId> <artifactId>XpenselyServer</artifactId>
<version>1.1.0</version> <version>1.2.2</version>
<name>XpenselyServer</name> <name>XpenselyServer</name>
<description>XpenselyServer used to handle the Xpensely App</description> <description>XpenselyServer used to handle the Xpensely App</description>
<url/> <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.*;
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto; 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.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;
@@ -97,6 +98,18 @@ public class ExpenseListController {
return new ResponseEntity<>(renamed, HttpStatus.OK); 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") @PostMapping("/{id}/add")
public ResponseEntity<Expense> addExpenseToList( public ResponseEntity<Expense> addExpenseToList(
@PathVariable("id") Long expenseListId, @PathVariable("id") Long expenseListId,
@@ -194,6 +207,14 @@ public class ExpenseListController {
return ResponseEntity.ok(historyService.getHistory(id, page, size)); 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) { 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);
@@ -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 com.fasterxml.jackson.annotation.JsonBackReference;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@@ -36,6 +38,12 @@ public class Expense {
private Double personalUseAmount; private Double personalUseAmount;
private Double otherPersonAmount; private Double otherPersonAmount;
private String category; private String category;
@Enumerated(EnumType.STRING)
private SplitMode splitMode;
private Integer splitPercentage;
private LocalDate date; private LocalDate date;
private LocalDateTime lastModified; private LocalDateTime lastModified;
@@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model;
import java.time.LocalDate; import java.time.LocalDate;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -33,6 +35,12 @@ public class ExpenseChangeRequest {
private Double personalUseAmount; private Double personalUseAmount;
private Double otherPersonAmount; private Double otherPersonAmount;
private SplitMode splitMode;
@Min(0)
@Max(100)
private Integer splitPercentage;
@NotNull(message = "Date is required") @NotNull(message = "Date is required")
private LocalDate date; private LocalDate date;
@@ -55,6 +63,8 @@ public class ExpenseChangeRequest {
// The edit dialog sends "" for a missing category; store null so the // The edit dialog sends "" for a missing category; store null so the
// stub state stays uniform and history diffs don't report null -> "". // stub state stays uniform and history diffs don't report null -> "".
expense.setCategory(category == null || category.isBlank() ? null : category); expense.setCategory(category == null || category.isBlank() ? null : category);
expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode);
expense.setSplitPercentage(splitPercentage);
return expense; return expense;
} }
@@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model;
import java.time.LocalDate; import java.time.LocalDate;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -33,6 +35,12 @@ public class ExpenseInput {
private Double personalUseAmount; private Double personalUseAmount;
private Double otherPersonAmount; private Double otherPersonAmount;
private SplitMode splitMode;
@Min(0)
@Max(100)
private Integer splitPercentage;
@NotNull(message = "Date is required") @NotNull(message = "Date is required")
private LocalDate date; private LocalDate date;
@@ -56,6 +64,8 @@ public class ExpenseInput {
expense.setOwner(appUser); expense.setOwner(appUser);
expense.setTitle(title); expense.setTitle(title);
expense.setCategory(category); expense.setCategory(category);
expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode);
expense.setSplitPercentage(splitPercentage);
return expense; return expense;
} }
@@ -31,6 +31,8 @@ public class ExpenseList {
private String name; private String name;
private Integer defaultSplitPercentage = 50;
private String inviteCode; private String inviteCode;
@JsonIgnore @JsonIgnore
private LocalDateTime inviteCodeExpiration; 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> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
Page<ExpenseHistoryEntry> findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
Long ownerId, Long sharedWithId, Pageable pageable);
@Query("select h.expenseId from ExpenseHistoryEntry h") @Query("select h.expenseId from ExpenseHistoryEntry h")
List<Long> findAllExpenseIds(); List<Long> findAllExpenseIds();
@@ -50,6 +50,13 @@ public class ExpenseListService {
return repository.save(list); 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) { public Optional<ExpenseList> findById(Long id) {
return repository.findById(id); return repository.findById(id);
} }
@@ -137,6 +144,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.setSplitMode(updatedExpense.getSplitMode());
existingExpense.setSplitPercentage(updatedExpense.getSplitPercentage());
existingExpense.setLastModified(LocalDateTime.now()); existingExpense.setLastModified(LocalDateTime.now());
Expense saved = expenseRepository.save(existingExpense); Expense saved = expenseRepository.save(existingExpense);
@@ -153,6 +162,8 @@ public class ExpenseListService {
copy.setPersonalUseAmount(e.getPersonalUseAmount()); copy.setPersonalUseAmount(e.getPersonalUseAmount());
copy.setOtherPersonAmount(e.getOtherPersonAmount()); copy.setOtherPersonAmount(e.getOtherPersonAmount());
copy.setCategory(e.getCategory()); copy.setCategory(e.getCategory());
copy.setSplitMode(e.getSplitMode());
copy.setSplitPercentage(e.getSplitPercentage());
copy.setDate(e.getDate()); copy.setDate(e.getDate());
copy.setOwner(e.getOwner()); copy.setOwner(e.getOwner());
return copy; 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.AppUser;
import de.zendric.app.xpensely_server.model.DTO.HistoryEntryDto; 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.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.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.model.ExpenseList;
@@ -68,6 +69,10 @@ public class HistoryService {
addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount()); addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount());
addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount()); addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount());
addIfChanged(changes, "category", before.getCategory(), after.getCategory()); 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, "date", before.getDate(), after.getDate());
addIfChanged(changes, "owner", addIfChanged(changes, "owner",
before.getOwner() == null ? null : before.getOwner().getUsername(), before.getOwner() == null ? null : before.getOwner().getUsername(),
@@ -104,6 +109,23 @@ public class HistoryService {
return new HistoryPageDto(entries, result.hasNext()); 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) { private HistoryEntryDto toDto(ExpenseHistoryEntry entry) {
List<FieldChange> changes = List.of(); List<FieldChange> changes = List.of();
if (entry.getChanges() != null) { if (entry.getChanges() != null) {
@@ -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.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.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.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;
@@ -313,6 +314,40 @@ class ExpenseListControllerTest {
.andExpect(status().isNotFound()); .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 --- // --- Duplicate-username invite guard ---
@Test @Test
@@ -370,4 +405,49 @@ class ExpenseListControllerTest {
mockMvc.perform(get("/api/expenselist/99/history")) mockMvc.perform(get("/api/expenselist/99/history"))
.andExpect(status().isNotFound()); .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.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.SplitMode;
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;
@@ -109,6 +110,29 @@ class ExpenseListServiceTest {
assertThat(beforeCaptor.getValue().getAmount()).isEqualTo(12.0); 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 @Test
void deleteById_deletesHistoryEntriesBeforeDeletingList() { void deleteById_deletesHistoryEntriesBeforeDeletingList() {
service.deleteById(10L); 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.AppUser;
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto; 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.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.model.FieldChange; import de.zendric.app.xpensely_server.model.FieldChange;
import de.zendric.app.xpensely_server.model.HistoryEntryType; 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.repo.ExpenseHistoryRepository;
import de.zendric.app.xpensely_server.services.HistoryService; import de.zendric.app.xpensely_server.services.HistoryService;
@@ -85,6 +87,22 @@ class HistoryServiceTest {
assertThat(changes).containsExactly(new FieldChange("owner", "alice", "ben")); 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 @Test
void diff_nullToValue_isReported() { void diff_nullToValue_isReported() {
Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice")); 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(); 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));
}
} }