diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java similarity index 92% rename from services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java rename to services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java index c88392b..c0715a3 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/HelloController.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java new file mode 100644 index 0000000..21c61d5 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java @@ -0,0 +1,84 @@ +package tum.devoops.memberservice.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.service.MemberService; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RestController +public class MemberController { + + private final MemberService memberService; + + public MemberController(MemberService memberService) { + this.memberService = memberService; + } + + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/") + public ResponseEntity> getAllMembers() { + return ResponseEntity.ok(memberService.getAllMembers()); + } + + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/{id}") + public ResponseEntity getMemberDetails(@PathVariable UUID id) { + Optional memberOptional = memberService.getMemberById(id); + return memberOptional.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @PreAuthorize("hasRole('admin')") + @PostMapping("/") + public ResponseEntity createMember(@Valid @RequestBody MemberCreate memberCreate, @AuthenticationPrincipal Jwt jwt) { + try { + Optional optionalMember = memberService.createMember(memberCreate, jwt.getTokenValue()); + if (optionalMember.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + Member member = optionalMember.get(); + return ResponseEntity.created(URI.create("/" + member.getId())).body(member); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @PreAuthorize("hasRole('admin') or hasRole('member') and #id.toString() == authentication.name") + @PatchMapping("/{id}") + public ResponseEntity updateMemberDetails( + @PathVariable UUID id, + @Valid @RequestBody MemberPartialUpdate memberPartialUpdate, + @AuthenticationPrincipal Jwt jwt) { + try { + Optional updated = memberService.updateMember(id, memberPartialUpdate, jwt.getTokenValue()); + return updated.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @PreAuthorize("hasRole('admin')") + @DeleteMapping("/{id}") + public ResponseEntity deleteMember(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { + boolean deleted = memberService.deleteMember(id, jwt.getTokenValue()); + return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java b/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java new file mode 100644 index 0000000..ea3da45 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/converter/MemberConverter.java @@ -0,0 +1,68 @@ +package tum.devoops.memberservice.converter; + +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; + +import java.time.LocalDate; +import java.util.UUID; + +public class MemberConverter { + public static Member convertMemberEntityToMember(MemberEntity memberEntity) { + return new Member( + memberEntity.getId(), + memberEntity.getFirstName(), + memberEntity.getLastName(), + memberEntity.getEmail(), + memberEntity.getBirthday(), + memberEntity.getPhoneNumber(), + memberEntity.getAddress(), + memberEntity.getJoiningDate(), + memberEntity.getInformation() + ); + } + + public static MemberEntity convertMemberCreateToMemberEntity(MemberCreate memberCreate, UUID id) { + return new MemberEntity( + id, + memberCreate.getFirstName(), + memberCreate.getLastName(), + memberCreate.getEmail(), + memberCreate.getBirthday(), + memberCreate.getPhoneNumber(), + memberCreate.getAddress(), + LocalDate.now(), + memberCreate.getInformation() + ); + } + + public static MemberSummary convertMemberEntityToMemberSummary(MemberEntity memberEntity) { + return new MemberSummary(memberEntity.getId(), memberEntity.getFirstName(), memberEntity.getLastName(), memberEntity.getEmail()); + } + + public static void applyPartialUpdate(MemberEntity entity, MemberPartialUpdate update) { + if (update.getFirstName() != null) { + entity.setFirstName(update.getFirstName()); + } + if (update.getLastName() != null) { + entity.setLastName(update.getLastName()); + } + if (update.getEmail() != null) { + entity.setEmail(update.getEmail()); + } + if (update.getBirthday() != null) { + entity.setBirthday(update.getBirthday()); + } + if (update.getPhoneNumber() != null) { + entity.setPhoneNumber(update.getPhoneNumber()); + } + if (update.getAddress() != null) { + entity.setAddress(update.getAddress()); + } + if (update.getInformation() != null) { + entity.setInformation(update.getInformation()); + } + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java index 54233e6..f38475f 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java @@ -3,19 +3,22 @@ import java.time.LocalDate; import java.util.UUID; +import java.util.Objects; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Table(schema = "member", name = "members") -@Getter @Setter @NoArgsConstructor +@Getter @Setter @NoArgsConstructor @AllArgsConstructor public class MemberEntity { @Id @@ -46,4 +49,25 @@ public class MemberEntity { @Column(name = "information", nullable = true, columnDefinition = "TEXT") private String information; + + @Override + public boolean equals(Object o) { + if (!(o instanceof MemberEntity other)) { + return false; + } + return Objects.equals(id, other.id) + && Objects.equals(firstName, other.firstName) + && Objects.equals(lastName, other.lastName) + && Objects.equals(email, other.email) + && Objects.equals(birthday, other.birthday) + && Objects.equals(phoneNumber, other.phoneNumber) + && Objects.equals(address, other.address) + && Objects.equals(joiningDate, other.joiningDate) + && Objects.equals(information, other.information); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, birthday, phoneNumber, address, joiningDate, information); + } } diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java new file mode 100644 index 0000000..821ae04 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/KeycloakService.java @@ -0,0 +1,107 @@ +package tum.devoops.memberservice.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +@Service +public class KeycloakService { + + private final RestClient restClient; + @Value("${keycloak.realm}") + private String realm; + + public KeycloakService(RestClient.Builder restClientBuilder, @Value("${keycloak.base-url}") String baseUrl) { + this.restClient = restClientBuilder.baseUrl(baseUrl).build(); + } + + public UUID createUser(MemberCreate member, String bearerToken) throws Exception { + String username = member.getEmail() != null ? member.getEmail() : (member.getFirstName() + member.getLastName()).toLowerCase(); + + List credentials = member.getPassword() != null + ? List.of(new Credential("password", member.getPassword(), false)) + : List.of(); + + UserRepresentation body = new UserRepresentation( + username, member.getFirstName(), member.getLastName(), member.getEmail(), true, credentials); + + ResponseEntity response; + + try { + response = restClient.post() + .uri("/admin/realms/{realm}/users", realm) + .header("Authorization", "Bearer " + bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.Conflict e) { + throw new IllegalAccessException("A keycloak user with this username/email already exists"); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to create a keycloak user"); + } + + URI location = response.getHeaders().getLocation(); + + if (location == null) { + throw new IllegalStateException("Keycloak did not return a location header after user creation"); + } + + String path = location.getPath(); + return UUID.fromString(path.substring(path.lastIndexOf("/") + 1)); + } + + public void updateUser(Member member, String bearerToken) throws HttpClientErrorException { + + UserRepresentation body = new UserRepresentation(member.getEmail(), member.getFirstName(), + member.getLastName(), member.getEmail(), true, List.of()); + + try { + restClient.put() + .uri("/admin/realms/{realm}/users/{id}", realm, member.getId()) + .header("Authorization", "Bearer " + bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.Conflict e) { + throw new IllegalArgumentException("A Keycloak user with this email already exists"); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to update this Keycloak user"); + } + } + + public void deleteUser(UUID keycloakId, String bearerToken) throws HttpClientErrorException, SecurityException { + try { + restClient.delete() + .uri("/admin/realms/{realm}/users/{id}", realm, keycloakId) + .header("Authorization", "Bearer " + bearerToken) + .retrieve() + .toBodilessEntity(); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Keycloak user not found: " + keycloakId); + } catch (HttpClientErrorException.Forbidden e) { + throw new SecurityException("Insufficient permissions to delete a keycloak user"); + } + } + + private record UserRepresentation( + String username, + String firstName, + String lastName, + String email, + boolean enabled, + List credentials + ) {} + + private record Credential(String type, String value, boolean temporary) {} +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java new file mode 100644 index 0000000..b1ea8ec --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java @@ -0,0 +1,103 @@ +package tum.devoops.memberservice.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import tum.devoops.memberservice.converter.MemberConverter; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.repository.MemberRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class MemberService { + + @Autowired + KeycloakService keycloakService; + + @Autowired + MemberRepository memberRepository; + + public List getAllMembers() { + return memberRepository.findAll().stream() + .map(MemberConverter::convertMemberEntityToMemberSummary) + .toList(); + } + + public Optional getMemberSummaryById(UUID id) { + return memberRepository.findById(id) + .map(MemberConverter::convertMemberEntityToMemberSummary); + } + + public Optional getMemberById(UUID id) { + return memberRepository.findById(id) + .map(MemberConverter::convertMemberEntityToMember); + } + + public Optional createMember(MemberCreate memberCreate, String bearerToken) { + if (memberRepository.findByEmail(memberCreate.getEmail()).isPresent()) { + throw new IllegalStateException("Email already in use by another member"); + } + + UUID id; + try { + id = keycloakService.createUser(memberCreate, bearerToken); + } catch (Exception e) { + return Optional.empty(); + } + + MemberEntity memberEntity = MemberConverter.convertMemberCreateToMemberEntity(memberCreate, id); + memberEntity = memberRepository.save(memberEntity); + + return Optional.of(MemberConverter.convertMemberEntityToMember(memberEntity)); + } + + public Optional updateMember(UUID memberId, MemberPartialUpdate update, String bearerToken) { + Optional optionalEntity = memberRepository.findById(memberId); + if (optionalEntity.isEmpty()) { + return Optional.empty(); + } + + MemberEntity entity = optionalEntity.get(); + + if (update.getEmail() != null) { + memberRepository.findByEmail(update.getEmail()) + .filter(existing -> !existing.getId().equals(memberId)) + .ifPresent(e -> { + throw new IllegalStateException("Email already in use by another member"); + }); + } + + MemberConverter.applyPartialUpdate(entity, update); + + try { + keycloakService.updateUser(MemberConverter.convertMemberEntityToMember(entity), bearerToken); + } catch (Exception e) { + return Optional.empty(); + } + + MemberEntity saved = memberRepository.save(entity); + return Optional.of(MemberConverter.convertMemberEntityToMember(saved)); + } + + public boolean deleteMember(UUID id, String bearerToken) { + Optional optionalMemberEntity = memberRepository.findById(id); + if (optionalMemberEntity.isEmpty()) { + return false; + } + + try { + keycloakService.deleteUser(id, bearerToken); + } catch (Exception e) { + return false; + } + + memberRepository.delete(optionalMemberEntity.get()); + return true; + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java deleted file mode 100644 index 0fc4502..0000000 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java +++ /dev/null @@ -1,27 +0,0 @@ -package tum.devoops.memberservice; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestPropertySource; - -/** - * Context-load smoke test. - * - * DataSource and JPA auto-configurations are excluded so the test can run - * without a live PostgreSQL instance. - */ -@SpringBootTest(properties = { - "spring.autoconfigure.exclude=" + - "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + - "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration" -}) -@TestPropertySource(properties = { - "spring.jpa.hibernate.ddl-auto=none" -}) -class MemberServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java similarity index 98% rename from services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java rename to services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java index c0efa3e..358f358 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/HelloControllerTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.controller; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java new file mode 100644 index 0000000..66f91b0 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java @@ -0,0 +1,466 @@ +package tum.devoops.memberservice.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import tum.devoops.memberservice.config.SecurityConfig; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.service.MemberService; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberController.class) +@Import(SecurityConfig.class) +public class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private MemberService memberService; + + private UUID id; + private MemberSummary memberSummary; + private MemberSummary memberSummary1; + private Member member; + private MemberCreate memberCreate; + private MemberPartialUpdate memberPartialUpdate; + private Member updatedMember; + private String mockToken; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + + memberSummary = new MemberSummary(id, "Alice", "Aberdeen", "alice.aberdeen@example.com"); + memberSummary1 = new MemberSummary(UUID.randomUUID(), "Bob", "Builder", "bob.the.builder@example.com"); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.now(), + "phoneNumber", + "address", + LocalDate.now(), + "information" + ); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName(member.getFirstName()); + memberCreate.setLastName(member.getLastName()); + memberCreate.setEmail(member.getEmail()); + memberCreate.setPassword("password123"); + memberCreate.setPhoneNumber(member.getPhoneNumber()); + memberCreate.setAddress(member.getAddress()); + memberCreate.setInformation(member.getInformation()); + memberCreate.setBirthday(member.getBirthday()); + + memberPartialUpdate = new MemberPartialUpdate(); + memberPartialUpdate.setFirstName("newFirstName"); + memberPartialUpdate.setLastName("newLastName"); + memberPartialUpdate.setEmail("newemail@email.com"); + + updatedMember = new Member( + id, + "newFirstName", + "newLastName", + "newemail@email.com", + LocalDate.now(), + "newPhoneNumber", + "newAddress", + LocalDate.now(), + "newInformation" + ); + + mockToken = "mock-token"; + } + + // Test cases for getAllMembers() endpoint + + @Test + @WithMockUser(roles = "member") + void getMembersAllowedForMember() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "admin") + void getMembersAllowedForAdmin() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "guest") + void getMembersForbiddenForWrongRole() throws Exception { + mockMvc.perform(get("/")) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void getMembersUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(get("/")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "member") + void getMembersContentType() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @WithMockUser(roles = "member") + void getMembersEmptyList() throws Exception { + when(memberService.getAllMembers()).thenReturn(List.of()); + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + @WithMockUser(roles = "member") + void getMemberNonEmptyList() throws Exception { + List list = List.of(memberSummary, memberSummary1); + when(memberService.getAllMembers()).thenReturn(list); + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(list))); + } + + // Test cases for getMemberDetails() endpoint + + @Test + @WithMockUser(roles = "member") + void getMemberDetailsAllowedForMember() throws Exception { + when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "admin") + void getMemberDetailsAllowedForAdmin() throws Exception { + when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(roles = "guest") + void getMemberDetailsForbiddenForWrongRole() throws Exception { + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void getMemberDetailsUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(roles = "member") + void getMemberDetailsContentType() throws Exception { + when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @WithMockUser(roles = "member") + void getMemberDetailsReturnsCorrectMember() throws Exception { + when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); + mockMvc.perform(get("/{id}", id)) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(member))); + } + + @Test + @WithMockUser(roles = "member") + void getMemberDetailsReturnsNotFound() throws Exception { + UUID randomId = UUID.randomUUID(); + when(memberService.getMemberById(randomId)).thenReturn(Optional.empty()); + mockMvc.perform(get("/{id}", randomId)) + .andExpect(status().isNotFound()); + } + + // Test cases for createMember() endpoint + + @Test + void createMemberAllowedForAdmin() throws Exception { + when(memberService.createMember(eq(memberCreate), anyString())).thenReturn(Optional.of(member)); + + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + .with(jwt() + .jwt(j -> j.tokenValue(mockToken)) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + )) + .andExpect(status().isCreated()) + .andExpect(content().json(objectMapper.writeValueAsString(member))); + } + + @Test + @WithMockUser(roles = "member") + void createMemberForbiddenForMember() throws Exception { + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + ) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void createMemberUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + ) + .andExpect(status().isUnauthorized()); + } + + @Test + void createMemberReturnsBadRequestOnKeycloakFailure() throws Exception { + when(memberService.createMember(any(), anyString())).thenReturn(Optional.empty()); + + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + .with(jwt() + .jwt(j -> j.tokenValue(mockToken)) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + )) + .andExpect(status().isBadRequest()); + } + + @Test + void createMemberReturnsConflictOnEmailConflict() throws Exception { + when(memberService.createMember(any(), anyString())) + .thenThrow(new IllegalStateException("Email already in use")); + + mockMvc.perform(post("/") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberCreate)) + .with(jwt() + .jwt(j -> j.tokenValue(mockToken)) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + )) + .andExpect(status().isConflict()); + } + + // Test cases for updateMemberDetails() endpoint + + @Test + void updateMemberAllowedForAdmin() throws Exception { + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); + + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(updatedMember))); + } + + @Test + void updateMemberForbiddenForMemberOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isForbidden()); + } + + @Test + void updateMemberAllowedForMemberSameId() throws Exception { + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); + + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isOk()); + } + + @Test + void updateMemberForbiddenForGuestOtherId() throws Exception { + UUID randomId = UUID.randomUUID(); + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(randomId.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isForbidden()); + } + + @Test + void updateMemberForbiddenForGuestSameId() throws Exception { + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void updateMemberUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(patch("/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isUnauthorized()); + } + + @Test + void updateMemberReturnsNotFoundForMissingMember() throws Exception { + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.empty()); + + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isNotFound()); + } + + @Test + void updateMemberReturnsConflictOnEmailConflict() throws Exception { + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())) + .thenThrow(new IllegalStateException("Email already in use")); + + mockMvc.perform(patch("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberPartialUpdate)) + ) + .andExpect(status().isConflict()); + } + + // Test cases for deleteMember() endpoint + + @Test + void deleteMemberAllowedForAdmin() throws Exception { + when(memberService.deleteMember(eq(id), anyString())).thenReturn(true); + + mockMvc.perform(delete("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + ) + .andExpect(status().isNoContent()); + } + + @Test + void deleteMemberForbiddenForMember() throws Exception { + mockMvc.perform(delete("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")) + ) + ) + .andExpect(status().isForbidden()); + } + + @Test + void deleteMemberForbiddenForGuest() throws Exception { + mockMvc.perform(delete("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")) + ) + ) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void deleteMemberUnauthorizedForAnonymous() throws Exception { + mockMvc.perform(delete("/{id}", id)) + .andExpect(status().isUnauthorized()); + } + + @Test + void deleteMemberReturnsNotFoundForMissingMember() throws Exception { + when(memberService.deleteMember(eq(id), anyString())).thenReturn(false); + + mockMvc.perform(delete("/{id}", id) + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")) + ) + ) + .andExpect(status().isNotFound()); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java new file mode 100644 index 0000000..2238751 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/converter/MemberConverterTest.java @@ -0,0 +1,164 @@ +package tum.devoops.memberservice.converter; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MemberConverterTest { + + UUID id; + + private MemberEntity memberEntity; + private Member member; + private MemberCreate memberCreate; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + LocalDate birthday = LocalDate.of(1990, 1, 1); + LocalDate joiningDate = LocalDate.of(2020, 6, 15); + + memberEntity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + birthday, + "phoneNumber", + "address", + joiningDate, + "information" + ); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + birthday, + "phoneNumber", + "address", + joiningDate, + "information" + ); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + memberCreate.setPassword("password123"); + memberCreate.setBirthday(birthday); + memberCreate.setPhoneNumber("phoneNumber"); + memberCreate.setAddress("address"); + memberCreate.setInformation("information"); + } + + // Verifies that every field of a MemberEntity is mapped onto the resulting Member + @Test + void convertMemberEntityToMemberMapsAllFields() { + Member result = MemberConverter.convertMemberEntityToMember(memberEntity); + + assertEquals(id, result.getId()); + assertEquals(memberEntity.getFirstName(), result.getFirstName()); + assertEquals(memberEntity.getLastName(), result.getLastName()); + assertEquals(memberEntity.getEmail(), result.getEmail()); + assertEquals(memberEntity.getBirthday(), result.getBirthday()); + assertEquals(memberEntity.getPhoneNumber(), result.getPhoneNumber()); + assertEquals(memberEntity.getAddress(), result.getAddress()); + assertEquals(memberEntity.getJoiningDate(), result.getJoiningDate()); + assertEquals(memberEntity.getInformation(), result.getInformation()); + } + + // Verifies that the provided id is used, the MemberCreate fields are copied and joiningDate is set to today + @Test + void convertMemberCreateToMemberEntityMapsFieldsAndSetsJoiningDate() { + LocalDate before = LocalDate.now(); + MemberEntity result = MemberConverter.convertMemberCreateToMemberEntity(memberCreate, id); + LocalDate after = LocalDate.now(); + + assertEquals(id, result.getId()); + assertEquals(memberCreate.getFirstName(), result.getFirstName()); + assertEquals(memberCreate.getLastName(), result.getLastName()); + assertEquals(memberCreate.getEmail(), result.getEmail()); + assertEquals(memberCreate.getBirthday(), result.getBirthday()); + assertEquals(memberCreate.getPhoneNumber(), result.getPhoneNumber()); + assertEquals(memberCreate.getAddress(), result.getAddress()); + assertEquals(memberCreate.getInformation(), result.getInformation()); + + // joiningDate is overridden with the current date rather than taken from the input + assertTrue(!result.getJoiningDate().isBefore(before) && !result.getJoiningDate().isAfter(after)); + } + + // Verifies that only id, firstName, lastName and email are mapped onto the summary + @Test + void convertMemberEntityToMemberSummaryMapsSummaryFields() { + MemberSummary result = MemberConverter.convertMemberEntityToMemberSummary(memberEntity); + + assertEquals(id, result.getId()); + assertEquals(memberEntity.getFirstName(), result.getFirstName()); + assertEquals(memberEntity.getLastName(), result.getLastName()); + assertEquals(memberEntity.getEmail(), result.getEmail()); + } + + // Verifies that null optional fields are preserved through the conversion + @Test + void convertMemberEntityToMemberPreservesNullOptionalFields() { + MemberEntity entity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + null, + null, + null, + null, + null + ); + + Member result = MemberConverter.convertMemberEntityToMember(entity); + + assertNull(result.getBirthday()); + assertNull(result.getPhoneNumber()); + assertNull(result.getAddress()); + assertNull(result.getJoiningDate()); + assertNull(result.getInformation()); + } + + // Verifies that non-null fields in a partial update overwrite the entity's existing values + @Test + void applyPartialUpdateOverwritesNonNullFields() { + MemberPartialUpdate update = new MemberPartialUpdate(); + update.setFirstName("newFirst"); + update.setEmail("new@email.com"); + + MemberConverter.applyPartialUpdate(memberEntity, update); + + assertEquals("newFirst", memberEntity.getFirstName()); + assertEquals("new@email.com", memberEntity.getEmail()); + } + + // Verifies that null fields in a partial update leave the entity's existing values unchanged + @Test + void applyPartialUpdateSkipsNullFields() { + MemberPartialUpdate update = new MemberPartialUpdate(); + update.setFirstName("newFirst"); + // lastName, email, etc. are null — should not be touched + + MemberConverter.applyPartialUpdate(memberEntity, update); + + assertEquals("lastName", memberEntity.getLastName()); + assertEquals("email@email.com", memberEntity.getEmail()); + assertEquals("phoneNumber", memberEntity.getPhoneNumber()); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java new file mode 100644 index 0000000..f814aa3 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/KeycloakServiceTest.java @@ -0,0 +1,170 @@ +package tum.devoops.memberservice.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +class KeycloakServiceTest { + + private static final String BASE_URL = "http://keycloak.test"; + private static final String REALM = "test-realm"; + private static final String TOKEN = "mock-token"; + private static final String USERS_URI = BASE_URL + "/admin/realms/" + REALM + "/users"; + + private MockRestServiceServer server; + private KeycloakService keycloakService; + + private UUID id; + private MemberCreate memberCreate; + private Member member; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + + keycloakService = new KeycloakService(builder, BASE_URL); + ReflectionTestUtils.setField(keycloakService, "realm", REALM); + + id = UUID.randomUUID(); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + memberCreate.setPassword("password123"); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + } + + // Verifies that a successful creation returns the id parsed from the Location header + @Test + void createUserReturnsIdFromLocationHeader() throws Exception { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED) + .header(HttpHeaders.LOCATION, USERS_URI + "/" + id)); + + UUID result = keycloakService.createUser(memberCreate, TOKEN); + + assertEquals(id, result); + } + + // Verifies that creation succeeds when no email is set (username falls back to the member's name) + @Test + void createUserWithoutEmailReturnsId() throws Exception { + memberCreate.setEmail(null); + + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED) + .header(HttpHeaders.LOCATION, USERS_URI + "/" + id)); + + UUID result = keycloakService.createUser(memberCreate, TOKEN); + + assertEquals(id, result); + } + + // Verifies that a 409 conflict is translated into an IllegalAccessException + @Test + void createUserThrowsOnConflict() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CONFLICT)); + + assertThrows(IllegalAccessException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void createUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a creation without a Location header fails with an IllegalStateException + @Test + void createUserThrowsWhenNoLocationHeader() { + server.expect(requestTo(USERS_URI)) + .andRespond(withStatus(HttpStatus.CREATED)); + + assertThrows(IllegalStateException.class, () -> keycloakService.createUser(memberCreate, TOKEN)); + } + + // Verifies that a successful update completes without throwing + @Test + void updateUserSucceeds() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NO_CONTENT)); + + keycloakService.updateUser(member, TOKEN); + } + + // Verifies that a 409 conflict is translated into an IllegalArgumentException + @Test + void updateUserThrowsOnConflict() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.CONFLICT)); + + assertThrows(IllegalArgumentException.class, () -> keycloakService.updateUser(member, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void updateUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.updateUser(member, TOKEN)); + } + + // Verifies that a successful deletion completes without throwing + @Test + void deleteUserSucceeds() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NO_CONTENT)); + + keycloakService.deleteUser(id, TOKEN); + } + + // Verifies that a 404 not found is translated into an IllegalArgumentException + @Test + void deleteUserThrowsOnNotFound() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThrows(IllegalArgumentException.class, () -> keycloakService.deleteUser(id, TOKEN)); + } + + // Verifies that a 403 forbidden is translated into a SecurityException + @Test + void deleteUserThrowsOnForbidden() { + server.expect(requestTo(USERS_URI + "/" + id)) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThrows(SecurityException.class, () -> keycloakService.deleteUser(id, TOKEN)); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java new file mode 100644 index 0000000..bb2ddf2 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java @@ -0,0 +1,318 @@ +package tum.devoops.memberservice.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.model.Member; +import tum.devoops.memberservice.model.MemberCreate; +import tum.devoops.memberservice.model.MemberPartialUpdate; +import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.repository.MemberRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + private static final String TOKEN = "mock-token"; + + @Mock + private MemberRepository memberRepository; + + @Mock + private KeycloakService keycloakService; + + @InjectMocks + private MemberService memberService; + + private UUID id; + private MemberEntity memberEntity; + private Member member; + private MemberSummary expectedSummary; + private MemberCreate memberCreate; + private MemberPartialUpdate partialUpdate; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + + memberEntity = new MemberEntity( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + + member = new Member( + id, + "firstName", + "lastName", + "email@email.com", + LocalDate.of(1990, 1, 1), + "phoneNumber", + "address", + LocalDate.of(2020, 6, 15), + "information" + ); + + expectedSummary = new MemberSummary(id, "firstName", "lastName", "email@email.com"); + + memberCreate = new MemberCreate(); + memberCreate.setFirstName("firstName"); + memberCreate.setLastName("lastName"); + memberCreate.setEmail("email@email.com"); + memberCreate.setPassword("password123"); + memberCreate.setBirthday(LocalDate.of(1990, 1, 1)); + memberCreate.setPhoneNumber("phoneNumber"); + memberCreate.setAddress("address"); + memberCreate.setInformation("information"); + + partialUpdate = new MemberPartialUpdate(); + partialUpdate.setFirstName("firstName"); + partialUpdate.setLastName("lastName"); + partialUpdate.setEmail("email@email.com"); + partialUpdate.setBirthday(LocalDate.of(1990, 1, 1)); + partialUpdate.setPhoneNumber("phoneNumber"); + partialUpdate.setAddress("address"); + partialUpdate.setInformation("information"); + } + + // Test cases for getAllMembers() + + // Verifies that an empty list is returned when the repository holds no members + @Test + void getAllMembersReturnsEmptyListWhenNoMembers() { + when(memberRepository.findAll()).thenReturn(List.of()); + + List result = memberService.getAllMembers(); + + assertTrue(result.isEmpty()); + } + + // Verifies that each entity is converted into a MemberSummary with its fields mapped + @Test + void getAllMembersReturnsSummaryPerEntity() { + when(memberRepository.findAll()).thenReturn(List.of(memberEntity)); + + List result = memberService.getAllMembers(); + + assertEquals(1, result.size()); + assertEquals(expectedSummary, result.getFirst()); + } + + // Test cases for getMemberSummaryById() + + // Verifies that a populated summary is returned when the member exists + @Test + void getMemberSummaryByIdReturnsSummaryWhenFound() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + Optional result = memberService.getMemberSummaryById(id); + + assertTrue(result.isPresent()); + assertEquals(expectedSummary, result.get()); + } + + // Verifies that an empty optional is returned when the member does not exist + @Test + void getMemberSummaryByIdReturnsEmptyWhenNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = memberService.getMemberSummaryById(id); + + assertTrue(result.isEmpty()); + } + + // Test cases for getMemberById() + + // Verifies that the full member is returned when it exists + @Test + void getMemberByIdReturnsMemberWhenFound() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + Optional result = memberService.getMemberById(id); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + } + + // Verifies that an empty optional is returned when the member does not exist + @Test + void getMemberByIdReturnsEmptyWhenNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = memberService.getMemberById(id); + + assertTrue(result.isEmpty()); + } + + // Test cases for createMember() + + // Verifies that creation throws when a member with the same email already exists + @Test + void createMemberThrowsWhenEmailExists() { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); + + assertThrows(IllegalStateException.class, () -> memberService.createMember(memberCreate, TOKEN)); + + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).save(any()); + } + + // Verifies that creation is rejected and nothing is persisted when Keycloak fails + @Test + void createMemberReturnsEmptyWhenKeycloakThrows() throws Exception { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(keycloakService.createUser(memberCreate, TOKEN)).thenThrow(new RuntimeException("keycloak down")); + + Optional result = memberService.createMember(memberCreate, TOKEN); + + assertTrue(result.isEmpty()); + verify(memberRepository, never()).save(any()); + } + + // Verifies that a member is created and returned when the email is free and Keycloak succeeds + @Test + void createMemberReturnsMemberOnSuccess() throws Exception { + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(keycloakService.createUser(memberCreate, TOKEN)).thenReturn(id); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.createMember(memberCreate, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Test cases for updateMember() + + // Verifies that update is rejected when the member does not exist + @Test + void updateMemberReturnsEmptyWhenMemberNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + + assertTrue(result.isEmpty()); + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).save(any()); + } + + // Verifies that an update is rejected when the email belongs to a different member + @Test + void updateMemberThrowsWhenEmailTakenByOther() { + MemberEntity otherMember = new MemberEntity( + UUID.randomUUID(), "other", "other", "email@email.com", + LocalDate.of(1990, 1, 1), "phoneNumber", "address", LocalDate.of(2020, 6, 15), "information" + ); + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(otherMember)); + + assertThrows(IllegalStateException.class, () -> memberService.updateMember(id, partialUpdate, TOKEN)); + + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).save(any()); + } + + // Verifies that an update succeeds when the email belongs to the same member + @Test + void updateMemberReturnsMemberWhenEmailBelongsToSameMember() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Verifies that an update succeeds when the email is not used by anyone + @Test + void updateMemberReturnsMemberWhenEmailUnused() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); + + Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + + assertTrue(result.isPresent()); + assertEquals(member, result.get()); + verify(memberRepository).save(any(MemberEntity.class)); + } + + // Verifies that an update is rejected and nothing is persisted when Keycloak fails + @Test + void updateMemberReturnsEmptyWhenKeycloakThrows() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); + doThrow(new RuntimeException("keycloak down")).when(keycloakService).updateUser(any(), any()); + + Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + + assertTrue(result.isEmpty()); + verify(memberRepository, never()).save(any()); + } + + // Test cases for deleteMember() + + // Verifies that deletion fails when the member does not exist in the repository + @Test + void deleteMemberReturnsFalseWhenMemberNotFound() { + when(memberRepository.findById(id)).thenReturn(Optional.empty()); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertFalse(result); + verifyNoInteractions(keycloakService); + verify(memberRepository, never()).delete(any()); + } + + // Verifies that deletion fails and nothing is removed when Keycloak fails + @Test + void deleteMemberReturnsFalseWhenKeycloakThrows() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + doThrow(new RuntimeException("keycloak down")).when(keycloakService).deleteUser(id, TOKEN); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertFalse(result); + verify(memberRepository, never()).delete(any()); + } + + // Verifies that deletion succeeds and the entity is removed when it exists + @Test + void deleteMemberReturnsTrueOnSuccess() { + when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); + + boolean result = memberService.deleteMember(id, TOKEN); + + assertTrue(result); + verify(memberRepository).delete(memberEntity); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java similarity index 98% rename from services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java rename to services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java index 48c6216..bb67044 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/SecurityConfigTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/SecurityConfigTest.java @@ -1,4 +1,4 @@ -package tum.devoops.memberservice; +package tum.devoops.memberservice.service; import java.util.List; import java.util.Map;