From b58008c8bc4ed4a84cb18befdb3bf64ba93db980 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 6 Jul 2026 18:52:42 +0200 Subject: [PATCH] fix: allow editing quick-add stub expenses (amount 0, no category) Quick Add deliberately creates stub expenses with amount 0 and no category (ExpenseInput allows both), but ExpenseChangeRequest required amount >= 0.01 and a non-blank category, so every edit of a stub that did not simultaneously fill in both fields was rejected with 400. Update validation now accepts the same states create can produce, and a blank category is normalized to null. Co-Authored-By: Claude Fable 5 --- .../model/ExpenseChangeRequest.java | 9 ++-- .../controller/ExpenseListControllerTest.java | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 20c161b..561ff11 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -24,8 +24,10 @@ public class ExpenseChangeRequest { @NotBlank(message = "Owner name is required") private String ownerName; + // Same bounds as ExpenseInput: Quick Add creates stub expenses with amount 0 + // and no category, and those stubs must remain editable. @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + @DecimalMin(value = "0.00", message = "Amount must not be negative") private Double amount; private Double personalUseAmount; @@ -34,7 +36,6 @@ public class ExpenseChangeRequest { @NotNull(message = "Date is required") private LocalDate date; - @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { @@ -51,7 +52,9 @@ public class ExpenseChangeRequest { expense.setId(id); expense.setOwner(appUser); expense.setTitle(title); - expense.setCategory(category); + // The edit dialog sends "" for a missing category; store null so the + // stub state stays uniform and history diffs don't report null -> "". + expense.setCategory(category == null || category.isBlank() ? null : category); return expense; } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 6391b1f..aba6b13 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -53,6 +53,54 @@ class ExpenseListControllerTest { .andExpect(jsonPath("$.title").exists()); } + // Quick Add creates stub expenses (amount 0, no category); updating such a + // stub must be allowed so the user can fill in the details afterwards. + @Test + void updateExpense_quickAddStub_zeroAmountAndNoCategory_returns200() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + when(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner))) + .thenAnswer(inv -> inv.getArgument(1)); + + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\"}")) + .andExpect(status().isOk()); + } + + @Test + void updateExpense_blankCategory_isStoredAsNull() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + when(expenseListService.updateExpense(eq(1L), any(Expense.class), eq(owner))) + .thenAnswer(inv -> inv.getArgument(1)); + + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":0.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"\"}")) + .andExpect(status().isOk()); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Expense.class); + org.mockito.Mockito.verify(expenseListService).updateExpense(eq(1L), captor.capture(), eq(owner)); + org.assertj.core.api.Assertions.assertThat(captor.getValue().getCategory()).isNull(); + } + + @Test + void updateExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(put("/api/expenselist/1/update") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":42,\"title\":\"Groceries\",\"ownerName\":\"alice\",\"amount\":-5.0," + + "\"personalUseAmount\":0.0,\"otherPersonAmount\":0.0,\"date\":\"2026-07-06\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + @Test void addExpense_negativeAmount_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/1/add")