Release 1.1.0 #16
@@ -6,11 +6,11 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.6</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
<relativePath/>
|
||||
</parent>
|
||||
<groupId>de.zendric.app</groupId>
|
||||
<artifactId>XpenselyServer</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<version>1.1.0</version>
|
||||
<name>XpenselyServer</name>
|
||||
<description>XpenselyServer used to handle the Xpensely App</description>
|
||||
<url/>
|
||||
@@ -52,10 +52,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<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.AppUserCreateRequest;
|
||||
import de.zendric.app.xpensely_server.model.UsernameUpdateRequest;
|
||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||
import de.zendric.app.xpensely_server.services.UserService;
|
||||
|
||||
@@ -31,11 +32,6 @@ public class AppUserController {
|
||||
return ResponseEntity.ok(userService.getUser(id));
|
||||
}
|
||||
|
||||
@GetMapping("/byName")
|
||||
public AppUser getUserByName(@RequestParam String username) {
|
||||
return userService.getUserByName(username);
|
||||
}
|
||||
|
||||
@GetMapping("/byGoogleId")
|
||||
public ResponseEntity<AppUser> getUserByGoogleId(@RequestParam String id, Authentication authentication) {
|
||||
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
|
||||
@@ -51,6 +47,14 @@ public class AppUserController {
|
||||
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
|
||||
public ResponseEntity<String> deleteUser(@RequestParam Long id, Authentication 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.services.CategoryService;
|
||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||
import de.zendric.app.xpensely_server.services.UserService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/expenselist")
|
||||
@@ -26,14 +25,12 @@ public class ExpenseListController {
|
||||
private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class);
|
||||
|
||||
private final ExpenseListService expenseListService;
|
||||
private final UserService userService;
|
||||
private final CategoryService categoryService;
|
||||
private final AuthenticatedUserResolver authenticatedUserResolver;
|
||||
|
||||
public ExpenseListController(ExpenseListService expenseListService, UserService userService,
|
||||
public ExpenseListController(ExpenseListService expenseListService,
|
||||
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
|
||||
this.expenseListService = expenseListService;
|
||||
this.userService = userService;
|
||||
this.categoryService = categoryService;
|
||||
this.authenticatedUserResolver = authenticatedUserResolver;
|
||||
}
|
||||
@@ -93,7 +90,7 @@ public class ExpenseListController {
|
||||
if (listOpt.isEmpty())
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
assertMember(user, listOpt.get());
|
||||
AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner());
|
||||
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
|
||||
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
|
||||
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
|
||||
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
|
||||
@@ -109,7 +106,7 @@ public class ExpenseListController {
|
||||
if (expenseListOpt.isEmpty())
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
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 updatedExpense = expenseListService.updateExpense(expenseListId, expense);
|
||||
return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
|
||||
@@ -156,6 +153,11 @@ public class ExpenseListController {
|
||||
if (list.getOwner().getId().equals(authenticatedUser.getId())) {
|
||||
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);
|
||||
expenseListService.save(list);
|
||||
return ResponseEntity.ok("User added to the list");
|
||||
@@ -173,4 +175,20 @@ public class ExpenseListController {
|
||||
if (!isOwner && !isShared)
|
||||
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;
|
||||
|
||||
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.UsernameAlreadyExistsException;
|
||||
import org.slf4j.Logger;
|
||||
@@ -47,6 +48,13 @@ public class GlobalExceptionHandler {
|
||||
.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)
|
||||
public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
|
||||
@@ -26,9 +26,10 @@ public class AppUser {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "username", nullable = false, unique = true)
|
||||
@Column(name = "username", nullable = false)
|
||||
private String username;
|
||||
|
||||
@Column(name = "google_id", unique = true)
|
||||
@JsonIgnore
|
||||
private String googleId;
|
||||
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
Boolean existsByUsername(String username);
|
||||
|
||||
Boolean existsByGoogleId(String googleId);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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.UsernameAlreadyExistsException;
|
||||
import de.zendric.app.xpensely_server.repo.UserRepository;
|
||||
|
||||
@Service
|
||||
@@ -22,9 +22,18 @@ public class UserService {
|
||||
}
|
||||
|
||||
public AppUser createUser(AppUser user) {
|
||||
if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) {
|
||||
throw new UsernameAlreadyExistsException("Username already exists");
|
||||
if (Boolean.TRUE.equals(userRepository.existsByGoogleId(user.getGoogleId()))) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||
import de.zendric.app.xpensely_server.services.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
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.*;
|
||||
|
||||
@WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = {
|
||||
OAuth2ClientAutoConfiguration.class,
|
||||
OAuth2ClientWebSecurityAutoConfiguration.class,
|
||||
OAuth2ResourceServerAutoConfiguration.class
|
||||
})
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@@ -99,4 +95,38 @@ class AppUserControllerTest {
|
||||
mockMvc.perform(delete("/api/users").param("id", "99"))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
+54
-6
@@ -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.model.AppUser;
|
||||
import de.zendric.app.xpensely_server.model.Expense;
|
||||
import de.zendric.app.xpensely_server.model.ExpenseList;
|
||||
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
|
||||
import de.zendric.app.xpensely_server.services.CategoryService;
|
||||
import de.zendric.app.xpensely_server.services.ExpenseListService;
|
||||
import de.zendric.app.xpensely_server.services.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
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.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(value = ExpenseListController.class, excludeAutoConfiguration = {
|
||||
OAuth2ClientAutoConfiguration.class,
|
||||
OAuth2ClientWebSecurityAutoConfiguration.class,
|
||||
OAuth2ResourceServerAutoConfiguration.class
|
||||
})
|
||||
@AutoConfigureMockMvc(addFilters = false)
|
||||
@@ -38,7 +36,6 @@ class ExpenseListControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
@MockitoBean ExpenseListService expenseListService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CategoryService categoryService;
|
||||
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
|
||||
|
||||
@@ -178,4 +175,55 @@ class ExpenseListControllerTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user