Release 1.1.0 #16

Merged
Cedric merged 21 commits from dev into main 2026-07-05 00:03:47 +02:00
11 changed files with 173 additions and 31 deletions
Showing only changes of commit d73d349a58 - Show all commits
+2 -6
View File
@@ -6,11 +6,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version> <version>4.0.6</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/>
</parent> </parent>
<groupId>de.zendric.app</groupId> <groupId>de.zendric.app</groupId>
<artifactId>XpenselyServer</artifactId> <artifactId>XpenselyServer</artifactId>
<version>1.0.0</version> <version>1.1.0</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/>
@@ -52,10 +52,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
@@ -9,6 +9,7 @@ import org.springframework.web.server.ResponseStatusException;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.AppUserCreateRequest; import de.zendric.app.xpensely_server.model.AppUserCreateRequest;
import de.zendric.app.xpensely_server.model.UsernameUpdateRequest;
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.UserService; import de.zendric.app.xpensely_server.services.UserService;
@@ -31,11 +32,6 @@ public class AppUserController {
return ResponseEntity.ok(userService.getUser(id)); return ResponseEntity.ok(userService.getUser(id));
} }
@GetMapping("/byName")
public AppUser getUserByName(@RequestParam String username) {
return userService.getUserByName(username);
}
@GetMapping("/byGoogleId") @GetMapping("/byGoogleId")
public ResponseEntity<AppUser> getUserByGoogleId(@RequestParam String id, Authentication authentication) { public ResponseEntity<AppUser> getUserByGoogleId(@RequestParam String id, Authentication authentication) {
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
@@ -51,6 +47,14 @@ public class AppUserController {
return new ResponseEntity<>(nUser, HttpStatus.CREATED); return new ResponseEntity<>(nUser, HttpStatus.CREATED);
} }
@PutMapping("/username")
public ResponseEntity<AppUser> updateUsername(@RequestBody @Valid UsernameUpdateRequest request,
Authentication authentication) {
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
AppUser updated = userService.updateUsername(self.getId(), request.getUsername());
return ResponseEntity.ok(updated);
}
@DeleteMapping @DeleteMapping
public ResponseEntity<String> deleteUser(@RequestParam Long id, Authentication authentication) { public ResponseEntity<String> deleteUser(@RequestParam Long id, Authentication authentication) {
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
@@ -17,7 +17,6 @@ import de.zendric.app.xpensely_server.model.*;
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.UserService;
@RestController @RestController
@RequestMapping("/api/expenselist") @RequestMapping("/api/expenselist")
@@ -26,14 +25,12 @@ public class ExpenseListController {
private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class); private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class);
private final ExpenseListService expenseListService; private final ExpenseListService expenseListService;
private final UserService userService;
private final CategoryService categoryService; private final CategoryService categoryService;
private final AuthenticatedUserResolver authenticatedUserResolver; private final AuthenticatedUserResolver authenticatedUserResolver;
public ExpenseListController(ExpenseListService expenseListService, UserService userService, public ExpenseListController(ExpenseListService expenseListService,
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
this.expenseListService = expenseListService; this.expenseListService = expenseListService;
this.userService = userService;
this.categoryService = categoryService; this.categoryService = categoryService;
this.authenticatedUserResolver = authenticatedUserResolver; this.authenticatedUserResolver = authenticatedUserResolver;
} }
@@ -93,7 +90,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());
AppUser expenseOwner = userService.getUserByName(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); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
@@ -109,7 +106,7 @@ public class ExpenseListController {
if (expenseListOpt.isEmpty()) if (expenseListOpt.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND); return new ResponseEntity<>(HttpStatus.NOT_FOUND);
assertMember(user, expenseListOpt.get()); assertMember(user, expenseListOpt.get());
AppUser expenseOwner = userService.getUserByName(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); Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
return new ResponseEntity<>(updatedExpense, HttpStatus.OK); return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
@@ -156,6 +153,11 @@ public class ExpenseListController {
if (list.getOwner().getId().equals(authenticatedUser.getId())) { if (list.getOwner().getId().equals(authenticatedUser.getId())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list");
} }
if (list.getOwner().getUsername().equals(authenticatedUser.getUsername())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(
"You and the list owner both use the username \"" + authenticatedUser.getUsername()
+ "\". Please change your username before joining this list.");
}
list.setSharedWith(authenticatedUser); list.setSharedWith(authenticatedUser);
expenseListService.save(list); expenseListService.save(list);
return ResponseEntity.ok("User added to the list"); return ResponseEntity.ok("User added to the list");
@@ -173,4 +175,20 @@ public class ExpenseListController {
if (!isOwner && !isShared) if (!isOwner && !isShared)
throw new ResponseStatusException(HttpStatus.FORBIDDEN); throw new ResponseStatusException(HttpStatus.FORBIDDEN);
} }
/**
* Resolves an expense owner by username among the list's members. Usernames are
* not globally unique, so the owner must be one of this list's (at most two)
* members — never a global lookup.
*/
private AppUser resolveListMember(ExpenseList list, String username) {
if (list.getOwner() != null && list.getOwner().getUsername().equals(username)) {
return list.getOwner();
}
if (list.getSharedWith() != null && list.getSharedWith().getUsername().equals(username)) {
return list.getSharedWith();
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Expense owner must be a member of this list");
}
} }
@@ -1,5 +1,6 @@
package de.zendric.app.xpensely_server.controller; package de.zendric.app.xpensely_server.controller;
import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException;
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -47,6 +48,13 @@ public class GlobalExceptionHandler {
.body(Map.of("error", ex.getMessage())); .body(Map.of("error", ex.getMessage()));
} }
@ExceptionHandler(GoogleAccountAlreadyRegisteredException.class)
public ResponseEntity<Map<String, String>> handleGoogleAccountConflict(
GoogleAccountAlreadyRegisteredException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(ResponseStatusException.class) @ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) { public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode()) return ResponseEntity.status(ex.getStatusCode())
@@ -26,9 +26,10 @@ public class AppUser {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(name = "username", nullable = false, unique = true) @Column(name = "username", nullable = false)
private String username; private String username;
@Column(name = "google_id", unique = true)
@JsonIgnore @JsonIgnore
private String googleId; private String googleId;
@@ -0,0 +1,11 @@
package de.zendric.app.xpensely_server.model.Exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.CONFLICT)
public class GoogleAccountAlreadyRegisteredException extends RuntimeException {
public GoogleAccountAlreadyRegisteredException(String message) {
super(message);
}
}
@@ -0,0 +1,15 @@
package de.zendric.app.xpensely_server.model;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class UsernameUpdateRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters")
@Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens")
private String username;
}
@@ -14,4 +14,6 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByGoogleId(String id); Optional<AppUser> findByGoogleId(String id);
Boolean existsByUsername(String username); Boolean existsByUsername(String username);
Boolean existsByGoogleId(String googleId);
} }
@@ -5,8 +5,8 @@ import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException;
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException;
import de.zendric.app.xpensely_server.repo.UserRepository; import de.zendric.app.xpensely_server.repo.UserRepository;
@Service @Service
@@ -22,9 +22,18 @@ public class UserService {
} }
public AppUser createUser(AppUser user) { public AppUser createUser(AppUser user) {
if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) { if (Boolean.TRUE.equals(userRepository.existsByGoogleId(user.getGoogleId()))) {
throw new UsernameAlreadyExistsException("Username already exists"); throw new GoogleAccountAlreadyRegisteredException(
"An account already exists for this Google account");
} }
// Usernames are not unique — identity is the Google account (googleId).
return userRepository.save(user);
}
public AppUser updateUsername(Long userId, String newUsername) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
user.setUsername(newUsername);
return userRepository.save(user); return userRepository.save(user);
} }
@@ -6,8 +6,6 @@ import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.UserService; import de.zendric.app.xpensely_server.services.UserService;
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.client.autoconfigure.OAuth2ClientAutoConfiguration;
import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration;
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -22,8 +20,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = { @WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = {
OAuth2ClientAutoConfiguration.class,
OAuth2ClientWebSecurityAutoConfiguration.class,
OAuth2ResourceServerAutoConfiguration.class OAuth2ResourceServerAutoConfiguration.class
}) })
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@@ -99,4 +95,38 @@ class AppUserControllerTest {
mockMvc.perform(delete("/api/users").param("id", "99")) mockMvc.perform(delete("/api/users").param("id", "99"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
// --- Rename username ---
@Test
void updateUsername_valid_returns200WithUpdatedUser() throws Exception {
AppUser self = new AppUser(); self.setId(1L); self.setUsername("old");
AppUser updated = new AppUser(); updated.setId(1L); updated.setUsername("newname");
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self);
when(userService.updateUsername(1L, "newname")).thenReturn(updated);
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"newname\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("newname"));
}
@Test
void updateUsername_invalidPattern_returns400() throws Exception {
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"bad name!\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.username").exists());
}
@Test
void updateUsername_tooShort_returns400() throws Exception {
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"ab\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.username").exists());
}
} }
@@ -2,15 +2,13 @@ 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.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.UserService;
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.client.autoconfigure.OAuth2ClientAutoConfiguration;
import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration;
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -19,17 +17,17 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(value = ExpenseListController.class, excludeAutoConfiguration = { @WebMvcTest(value = ExpenseListController.class, excludeAutoConfiguration = {
OAuth2ClientAutoConfiguration.class,
OAuth2ClientWebSecurityAutoConfiguration.class,
OAuth2ResourceServerAutoConfiguration.class OAuth2ResourceServerAutoConfiguration.class
}) })
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@@ -38,7 +36,6 @@ class ExpenseListControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean ExpenseListService expenseListService; @MockitoBean ExpenseListService expenseListService;
@MockitoBean UserService userService;
@MockitoBean CategoryService categoryService; @MockitoBean CategoryService categoryService;
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver; @MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
@@ -178,4 +175,55 @@ class ExpenseListControllerTest {
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.name").exists()); .andExpect(jsonPath("$.name").exists());
} }
// --- Owner resolution within list members ---
@Test
void addExpense_ownerNotAListMember_returns400() throws Exception {
AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice");
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member);
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Lunch\",\"owner\":\"ghost\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}"))
.andExpect(status().isBadRequest());
}
@Test
void addExpense_ownerIsAListMember_returns201() throws Exception {
AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice");
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member);
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense());
mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}"))
.andExpect(status().isCreated());
}
// --- Duplicate-username invite guard ---
@Test
void acceptInvite_sameUsernameAsOwner_returns409() throws Exception {
AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("cedric");
AppUser joiner = new AppUser(); joiner.setId(2L); joiner.setUsername("cedric");
ExpenseList list = new ExpenseList();
list.setId(1L);
list.setOwner(owner);
list.setInviteCodeExpiration(LocalDateTime.now().plusHours(1));
when(expenseListService.findByInviteCode("ABC123")).thenReturn(list);
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(joiner);
mockMvc.perform(post("/api/expenselist/accept-invite")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"inviteCode\":\"ABC123\"}"))
.andExpect(status().isConflict());
}
} }