Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

EXPOSE 8080
EXPOSE 9090

ENTRYPOINT ["java", "-jar", "app.jar"]
30 changes: 30 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import com.google.protobuf.gradle.*

plugins {
java
jacoco
id("org.springframework.boot") version "4.0.1"
id("io.spring.dependency-management") version "1.1.7"
id("com.google.protobuf") version "0.9.4"
}

group = "com.devoops"
Expand All @@ -19,6 +22,8 @@ repositories {
mavenCentral()
}

val grpcVersion = "1.68.0"

dependencies {
// Web and Core
implementation("org.springframework.boot:spring-boot-starter-webmvc")
Expand Down Expand Up @@ -55,6 +60,13 @@ dependencies {
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0")

// gRPC Server
implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
implementation("io.grpc:grpc-stub:$grpcVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
compileOnly("javax.annotation:javax.annotation-api:1.3.2")

// Tracing (Zipkin)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
Expand Down Expand Up @@ -89,3 +101,21 @@ tasks.jacocoTestReport {
xml.required = true
}
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.5"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("grpc")
}
}
}
}
69 changes: 69 additions & 0 deletions src/main/java/com/devoops/user/grpc/UserGrpcService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.devoops.user.grpc;

import com.devoops.user.entity.User;
import com.devoops.user.grpc.proto.GetUserSummaryRequest;
import com.devoops.user.grpc.proto.GetUserSummaryResponse;
import com.devoops.user.grpc.proto.UserInternalServiceGrpc;
import com.devoops.user.repository.UserRepository;
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.devh.boot.grpc.server.service.GrpcService;

import java.util.Optional;
import java.util.UUID;

@GrpcService
@RequiredArgsConstructor
@Slf4j
public class UserGrpcService extends UserInternalServiceGrpc.UserInternalServiceImplBase {

private final UserRepository userRepository;

@Override
public void getUserSummary(
GetUserSummaryRequest request,
StreamObserver<GetUserSummaryResponse> responseObserver) {

log.debug("Received GetUserSummary request for userId: {}", request.getUserId());

GetUserSummaryResponse response = processRequest(request);

responseObserver.onNext(response);
responseObserver.onCompleted();
}

private GetUserSummaryResponse processRequest(GetUserSummaryRequest request) {
UUID userId;
try {
userId = UUID.fromString(request.getUserId());
} catch (IllegalArgumentException e) {
log.warn("Invalid user ID format: {}", request.getUserId());
return buildNotFoundResponse();
}

Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
log.debug("User not found: {}", userId);
return buildNotFoundResponse();
}

User user = userOpt.get();
log.debug("Found user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole());

return GetUserSummaryResponse.newBuilder()
.setFound(true)
.setUserId(user.getId().toString())
.setEmail(user.getEmail())
.setFirstName(user.getFirstName())
.setLastName(user.getLastName())
.setRole(user.getRole().name())
.build();
}

private GetUserSummaryResponse buildNotFoundResponse() {
return GetUserSummaryResponse.newBuilder()
.setFound(false)
.build();
}
}
22 changes: 22 additions & 0 deletions src/main/proto/user_internal.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
syntax = "proto3";
package user;

option java_multiple_files = true;
option java_package = "com.devoops.user.grpc.proto";

service UserInternalService {
rpc GetUserSummary(GetUserSummaryRequest) returns (GetUserSummaryResponse);
}

message GetUserSummaryRequest {
string user_id = 1;
}

message GetUserSummaryResponse {
bool found = 1;
string user_id = 2;
string email = 3;
string first_name = 4;
string last_name = 5;
string role = 6;
}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ spring.rabbitmq.virtual-host=${RABBITMQ_VHOST:/}
# Queue config
rabbitmq.exchange.notification=notification.exchange
rabbitmq.routing-key.user-created=user.created

# gRPC Server
grpc.server.port=${GRPC_PORT:9090}
204 changes: 204 additions & 0 deletions src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package com.devoops.user.grpc;

import com.devoops.user.entity.Role;
import com.devoops.user.entity.User;
import com.devoops.user.grpc.proto.GetUserSummaryRequest;
import com.devoops.user.grpc.proto.GetUserSummaryResponse;
import com.devoops.user.repository.UserRepository;
import io.grpc.stub.StreamObserver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserGrpcServiceTest {

@Mock
private UserRepository userRepository;

@Mock
private StreamObserver<GetUserSummaryResponse> responseObserver;

@InjectMocks
private UserGrpcService userGrpcService;

private User testUser;
private UUID testUserId;

@BeforeEach
void setUp() {
testUserId = UUID.randomUUID();
testUser = buildTestUser();
}

private User buildTestUser() {
return User.builder()
.id(testUserId)
.username("testuser")
.password("encoded_password")
.email("test@example.com")
.firstName("Test")
.lastName("User")
.residence("Test City")
.role(Role.GUEST)
.build();
}

@Nested
@DisplayName("getUserSummary Tests")
class GetUserSummaryTests {

@Test
@DisplayName("Should return user summary when user exists")
void getUserSummary_WithExistingUser_ReturnsSummary() {
// Given
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.setUserId(testUserId.toString())
.build();
when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser));

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());
verify(responseObserver).onCompleted();
verify(responseObserver, never()).onError(any());

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isTrue();
assertThat(response.getUserId()).isEqualTo(testUserId.toString());
assertThat(response.getEmail()).isEqualTo("test@example.com");
assertThat(response.getFirstName()).isEqualTo("Test");
assertThat(response.getLastName()).isEqualTo("User");
assertThat(response.getRole()).isEqualTo("GUEST");
}

@Test
@DisplayName("Should return not found when user does not exist")
void getUserSummary_WithNonExistingUser_ReturnsNotFound() {
// Given
UUID unknownId = UUID.randomUUID();
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.setUserId(unknownId.toString())
.build();
when(userRepository.findById(unknownId)).thenReturn(Optional.empty());

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());
verify(responseObserver).onCompleted();
verify(responseObserver, never()).onError(any());

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isFalse();
assertThat(response.getUserId()).isEmpty();
assertThat(response.getEmail()).isEmpty();
}

@Test
@DisplayName("Should return not found when user ID is invalid UUID")
void getUserSummary_WithInvalidUUID_ReturnsNotFound() {
// Given
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.setUserId("invalid-uuid")
.build();

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());
verify(responseObserver).onCompleted();
verify(responseObserver, never()).onError(any());

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isFalse();

// Repository should not be called for invalid UUID
verify(userRepository, never()).findById(any());
}

@Test
@DisplayName("Should return not found when user ID is empty")
void getUserSummary_WithEmptyUserId_ReturnsNotFound() {
// Given
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.setUserId("")
.build();

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());
verify(responseObserver).onCompleted();

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isFalse();

verify(userRepository, never()).findById(any());
}

@Test
@DisplayName("Should return correct role for HOST user")
void getUserSummary_WithHostUser_ReturnsHostRole() {
// Given
testUser.setRole(Role.HOST);
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.setUserId(testUserId.toString())
.build();
when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser));

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isTrue();
assertThat(response.getRole()).isEqualTo("HOST");
}

@Test
@DisplayName("Should handle null user ID gracefully")
void getUserSummary_WithNullUserId_ReturnsNotFound() {
// Given - protobuf returns empty string for null
GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder()
.build();

// When
userGrpcService.getUserSummary(request, responseObserver);

// Then
ArgumentCaptor<GetUserSummaryResponse> captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class);
verify(responseObserver).onNext(captor.capture());
verify(responseObserver).onCompleted();

GetUserSummaryResponse response = captor.getValue();
assertThat(response.getFound()).isFalse();

verify(userRepository, never()).findById(any());
}
}
}