diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f97bf2bc..e2f55779 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,12 @@ Once database is setup you can run : > :warning: **You have to generate keystore.jks before!**, see [README](https://github.com/ditrit/leto-modelizer-api/blob/main/README.md#Generate-certificate-for-HTTPS). +If you do not want to have a real AI, you can use a fake AI: +```shell +docker build -t leto-modelizer-ai-proxy -f ./src/test/resources/ai/Dockerfile ./src/test/resources/ai +docker run -p 8585:8585 -v $(pwd)/src/test/resources/ai:/var/www/html --restart always leto-modelizer-ai-proxy +``` + ## Run application tests To run all the application tests (unit and integration), use this command: diff --git a/README.md b/README.md index 5b4d503c..e1d3f516 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ enabling secure and streamlined user authentication. | CSRF_TOKEN_TIMEOUT | No, default: `3600` | A configuration parameter that specifies the duration (in seconds) for which a Cross-Site Request Forgery (CSRF) token remains valid. This setting is used to prevent CSRF attacks by ensuring that the token used in a client session expires after a certain period, requiring a new token for future requests. | | USER_SESSION_TIMEOUT | No, default: `3600` | A configuration parameter that defines the time (in seconds) a user's session remains active without any activity. After this period, the user is automatically logged out to help protect against unauthorized access and to manage server resource utilization efficiently. | | SUPER_ADMINISTRATOR_LOGIN | No | A configuration parameter that defines the username on Github of the SUPER_ADMINISTRATOR. It will create user if it doesn't exist and associate it to the `SUPER_ADMINISTRATOR` role. | -| AI_HOST | No | A configuration parameter that defines the host of the ia server, example: http://localhost:8001/api/. If it's not set, users will not be approve to use ia in application. | +| AI_HOST | No, default: `http://localhost:8585/` | A configuration parameter that defines the host of the ia server, example: http://localhost:8585/api/. If it's not set, users will not be approve to use ia in application. | > Notes: `GITHUB_ENTERPRISE_*` variables are only required on self-hosted GitHub. @@ -275,7 +275,7 @@ LETO_ADMIN_URL=http://localhost:9000/ LIBRARY_HOST_WHITELIST=https://github.com/ditrit/ CSRF_TOKEN_TIMEOUT=3600 USER_SESSION_TIMEOUT=3600 -AI_HOST=http://locahost:8001/api/ +AI_HOST=http://locahost:8585/api/ ``` See Configuration section for more details. diff --git a/build.gradle b/build.gradle index af3a8da9..fb6b4a72 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,10 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.2.4' - id 'io.spring.dependency-management' version '1.1.4' + id 'org.springframework.boot' version '3.3.3' + id 'io.spring.dependency-management' version '1.1.6' id 'checkstyle' id 'com.github.ben-manes.versions' version '0.51.0' - id 'org.sonarqube' version '5.0.0.4638' + id 'org.sonarqube' version '5.1.0.4882' id 'com.adarshr.test-logger' version '4.0.0' id 'jacoco' id 'idea' @@ -44,19 +44,19 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.session:spring-session-jdbc:3.2.2' - implementation 'org.flywaydb:flyway-core:10.10.0' - implementation "org.flywaydb:flyway-database-postgresql:10.10.0" + implementation 'org.springframework.session:spring-session-jdbc:3.3.2' + implementation 'org.flywaydb:flyway-core:10.17.3' + implementation "org.flywaydb:flyway-database-postgresql:10.17.3" implementation 'commons-lang:commons-lang:2.6' implementation 'commons-beanutils:commons-beanutils:1.9.4' - implementation 'com.github.erosb:json-sKema:0.14.0' + implementation 'com.github.erosb:json-sKema:0.16.0' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql:42.7.3' + runtimeOnly 'org.postgresql:postgresql:42.7.4' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'io.cucumber:cucumber-java:7.16.1' - testImplementation 'io.cucumber:cucumber-junit:7.16.1' - testImplementation 'org.junit.vintage:junit-vintage-engine:5.10.2' + testImplementation 'io.cucumber:cucumber-java:7.18.1' + testImplementation 'io.cucumber:cucumber-junit:7.18.1' + testImplementation 'org.junit.vintage:junit-vintage-engine:5.11.0' } tasks.named('test') { diff --git a/changelog.md b/changelog.md index cba8f6b1..2019cc53 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `GET /api/user/me/roles`, to get its roles. * `GET /api/user/me/groups`, to get its groups. * `GET /api/user/me/scopes`, to get its scopes. + * `GET /api/users/me/ai/conversations`, to get its conversations. * For all users: * `GET /api/users`, to get all users. * `GET /api/users/{login}`, to get user by login. @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `GET /api/users/{login}/scopes`, to get all scopes associated to the user. * `GET /api/users/{login}/permissions`, to get all permissions associated to the user. * `GET /api/users/{login}/picture`, to get picture associated to the user. + * `GET /api/users/{login}/ai/conversations`, to get all conversations associated to the user. * For all roles: * `GET /api/roles`, to get all roles. * `POST /api/roles`, to create a role. @@ -90,6 +92,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `GET /api/libraries/templates/[TEMPLATE_ID]/schemas/[INDEX]`, to get schema of template. * `GET /api/libraries/templates/[TEMPLATE_ID]/files/[INDEX]`, to get file of template. * For AI: - * `GET /api/ia`, to create diagram with AI. + * `GET /api/ai/generate`, to generate diagram with AI. + * `GET /api/ai/conversations`, to get all conversation. + * `POST /api/ai/conversations`, to create a conversation. + * `GET /api/ai/conversations/[CONVERSATION_ID]`, to get a conversation by id. + * `DELETE /api/ai/conversations/[CONVERSATION_ID]`, to delete a conversation. + * `GET /api/ai/conversations/[CONVERSATION_ID]/messages`, to get all messages of a conversations. + * `POST /api/ai/conversations/[CONVERSATION_ID]/messages`, to send a message to AI. * `/api/login`, to login. * `/api/redirect`, to redirect with token on leto-modelizer/leto-modelizer-admin. diff --git a/docker-compose-e2e.yml b/docker-compose-e2e.yml index b58f5676..cb14e4af 100644 --- a/docker-compose-e2e.yml +++ b/docker-compose-e2e.yml @@ -10,7 +10,7 @@ services: ai: build: ./src/test/resources/ai ports: - - "8001:80" + - "8585:80" volumes: - ./src/test/resources/ai:/var/www/html restart: always @@ -29,7 +29,7 @@ services: image: leto-modelizer-api:latest environment: DATABASE_HOST: db:26257 - AI_HOST: http://ai/api/ + AI_HOST: http://ai/ GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} LIBRARY_HOST_WHITELIST: http://libraries/ diff --git a/docker-compose.yml b/docker-compose.yml index 32cf4c41..924ec474 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: image: leto-modelizer-api:latest environment: DATABASE_HOST: db:26257 - AI_HOST: http://ai/api/ + AI_HOST: http://ai/ GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} LIBRARY_HOST_WHITELIST: http://libraries/ diff --git a/src/main/java/com/ditrit/letomodelizerapi/config/Constants.java b/src/main/java/com/ditrit/letomodelizerapi/config/Constants.java index f988858c..f657c909 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/config/Constants.java +++ b/src/main/java/com/ditrit/letomodelizerapi/config/Constants.java @@ -26,6 +26,16 @@ public final class Constants { */ public static final String DEFAULT_USER_PROPERTY = "login"; + /** + * The constant representing the default context property. + */ + public static final String DEFAULT_CONTEXT_PROPERTY = "context"; + + /** + * The constant representing the default update date property. + */ + public static final String DEFAULT_UPDATE_DATE_PROPERTY = "updateDate"; + /** * Private constructor. */ diff --git a/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java b/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java index b8a7a6d0..698acbf3 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java +++ b/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java @@ -1,25 +1,48 @@ package com.ditrit.letomodelizerapi.controller; -import com.ditrit.letomodelizerapi.model.ai.AIRequestRecord; + +import com.ditrit.letomodelizerapi.controller.model.QueryFilter; +import com.ditrit.letomodelizerapi.model.BeanMapper; +import com.ditrit.letomodelizerapi.model.ai.AIConversationDTO; +import com.ditrit.letomodelizerapi.model.ai.AIConversationRecord; +import com.ditrit.letomodelizerapi.model.ai.AICreateFileRecord; +import com.ditrit.letomodelizerapi.model.ai.AIMessageDTO; +import com.ditrit.letomodelizerapi.model.mapper.ai.AIMessageToDTOMapper; +import com.ditrit.letomodelizerapi.model.permission.ActionPermission; +import com.ditrit.letomodelizerapi.model.permission.EntityPermission; import com.ditrit.letomodelizerapi.persistence.model.User; import com.ditrit.letomodelizerapi.service.AIService; +import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; +import com.fasterxml.jackson.core.JsonProcessingException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; + /** * Controller to manage ai endpoint. */ @@ -28,7 +51,7 @@ @Controller @Slf4j @AllArgsConstructor(onConstructor = @__(@Autowired)) -public class AIController { +public class AIController implements DefaultController { /** * Service to manage user. @@ -36,40 +59,231 @@ public class AIController { private UserService userService; /** - * Service to manage ai request. + * Service to manage user permissions. + */ + private UserPermissionService userPermissionService; + + /** + * Service to manage AI request. */ private AIService aiService; /** - * Handles a POST request to initiate an interaction with an Artificial Intelligence (AI) based on the provided + * Handles a POST request to generate files with an Artificial Intelligence (AI) based on the provided * request details. - * This endpoint accepts an AI request record, which includes the parameters necessary for the AI interaction. + * This endpoint accepts an AI request record, which includes the parameters necessary for the files generation. * The method retrieves the user from the session, logs the request details, and forwards the request to the AI * service. It then constructs and returns a response containing the AI's output. * *

The method uses the AI service to process the request by the user, generating a JSON response that is returned * to the client. - * This process allows for dynamic interactions with the AI, facilitating use cases such as querying for - * information, executing commands, or initiating workflows within the application. * * @param request the HttpServletRequest, used to access the user's session. - * @param aiRequestRecord the request details for the AI, validated to ensure it meets the expected format. - * @return a Response object containing the AI's response in JSON format, with a status of OK (200). + * @param aiCreateFileRecord the request details for the AI, validated to ensure it meets the expected format. + * @return a Response object containing the AI's response in JSON format, with a status of CREATED (201). */ - @POST - public Response requestAI(final @Context HttpServletRequest request, - final @Valid AIRequestRecord aiRequestRecord) throws InterruptedException { + @Path("/generate") + public Response generateFiles(final @Context HttpServletRequest request, + final @Valid AICreateFileRecord aiCreateFileRecord) { HttpSession session = request.getSession(); User user = userService.getFromSession(session); - log.info("[{}] Received POST request to request AI with {}", user.getLogin(), aiRequestRecord); + log.info("[{}] Received POST request to generate files with AI with {}", + user.getLogin(), aiCreateFileRecord); - String json = aiService.sendRequest(aiRequestRecord); + String json = aiService.createFile(aiCreateFileRecord); return Response.status(HttpStatus.CREATED.value()) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .entity(json) .build(); } + + /** + * Handles a POST request to create a new AI conversation. + * This endpoint allows users to initiate a conversation with AI by providing the necessary details. + * + * @param request the HttpServletRequest used to access the user's session. + * @param aiConversationRecord the details of the AI conversation to be created, validated for correctness. + * @return a Response object containing the created AI conversation DTO with a status of CREATED (201). + * @throws JsonProcessingException if there is an error processing the request data. + */ + @POST + @Path("/conversations") + public Response createConversations(final @Context HttpServletRequest request, + final @Valid AIConversationRecord aiConversationRecord) + throws JsonProcessingException { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + + log.info("[{}] Received POST Received GET request to create AI conversation with {}", + user.getLogin(), aiConversationRecord); + + AIConversationDTO dto = new BeanMapper<>(AIConversationDTO.class) + .apply(aiService.createConversation(user, aiConversationRecord)); + + return Response.status(HttpStatus.CREATED.value()).entity(dto).build(); + } + + /** + * Handles a GET request to retrieve all AI conversations with optional filtering and pagination. + * This endpoint allows administrators to view all AI conversations. + * + * @param request the HttpServletRequest used to access the user's session. + * @param uriInfo UriInfo object to retrieve query parameters. + * @param queryFilter the filter criteria and pagination information. + * @return a Response object containing a paginated list of AIConversationDTOs. + */ + @GET + @Path("/conversations") + public Response findAllConversations(final @Context HttpServletRequest request, + final @Context UriInfo uriInfo, + final @BeanParam @Valid QueryFilter queryFilter) { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + userPermissionService.checkIsAdmin(user, null); + Map filters = this.getFilters(uriInfo); + + log.info("[{}] Received GET request to get AI conversations with the following filters: {}", + user.getLogin(), filters); + + Page resources = aiService.findAllConversations(filters, queryFilter.getPagination()) + .map(new BeanMapper<>(AIConversationDTO.class)); + + return Response.status(getStatus(resources)).entity(resources).build(); + } + + /** + * Handles a GET request to retrieve a specific AI conversation by its ID. + * This endpoint allows users to view details of a specific conversation. + * + * @param request the HttpServletRequest used to access the user's session. + * @param id the ID of the AI conversation to retrieve. + * @return a Response object containing the AIConversationDTO with a status of OK (200). + */ + @GET + @Path("/conversations/{id}") + public Response getConversationById(final @Context HttpServletRequest request, + final @PathParam("id") @Valid @NotNull UUID id) { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + + log.info("[{}] Received GET request to get AI conversation with id {}", user.getLogin(), id); + + AIConversationDTO dto = new BeanMapper<>(AIConversationDTO.class) + .apply(aiService.getConversationById(user, id)); + + return Response.status(HttpStatus.OK.value()).entity(dto).build(); + } + + /** + * Handles a PUT request to update an existing AI conversation by its ID. + * This endpoint allows users to modify the details of an existing conversation. + * + * @param request the HttpServletRequest used to access the user's session. + * @param id the ID of the AI conversation to update. + * @param aiConversationRecord the new details for the AI conversation, validated for correctness. + * @return a Response object containing the updated AIConversationDTO with a status of OK (200). + * @throws JsonProcessingException if there is an error processing the request data. + */ + @PUT + @Path("/conversations/{id}") + public Response updateConversationById(final @Context HttpServletRequest request, + final @PathParam("id") @Valid @NotNull UUID id, + final @Valid AIConversationRecord aiConversationRecord) + throws JsonProcessingException { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + + log.info("[{}] Received PUT request to update AI conversation with id {} with {}", + user.getLogin(), id, aiConversationRecord); + + AIConversationDTO dto = new BeanMapper<>(AIConversationDTO.class) + .apply(aiService.updateConversationById(user, id, aiConversationRecord)); + + return Response.status(HttpStatus.OK.value()).entity(dto).build(); + } + + /** + * Handles a DELETE request to remove a specific AI conversation by its ID. + * This endpoint allows users to delete an AI conversation. + * + * @param request the HttpServletRequest used to access the user's session. + * @param id the ID of the AI conversation to delete. + * @return a Response object with a status of NO CONTENT (204) upon successful deletion. + */ + @DELETE + @Path("/conversations/{id}") + public Response deleteConversationById(final @Context HttpServletRequest request, + final @PathParam("id") @Valid @NotNull UUID id) { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + + log.info("[{}] Received GET request to get AI conversation with id {}", user.getLogin(), id); + + if (userPermissionService.hasPermission(user, EntityPermission.ADMIN, ActionPermission.ACCESS)) { + aiService.deleteConversationById(id); + } else { + aiService.deleteConversationById(user, id); + } + + return Response.noContent().build(); + } + + /** + * Handles a POST request to send a message to a specific AI conversation. + * This endpoint allows users to communicate with the AI by sending a message to an exinsting conversation. + * + * @param request the HttpServletRequest used to access the user's session. + * @param id the ID of the AI conversation to which the message is sent. + * @param message the content of the message to send to the AI. + * @return a Response object containing the AI's reply in plain text with a status of CREATED (201). + * @throws JsonProcessingException if there is an error processing the request data. + */ + @POST + @Path("/conversations/{id}/messages") + public Response createConversationMessage(final @Context HttpServletRequest request, + final @PathParam("id") @Valid @NotNull UUID id, + final @Valid @NotNull String message) throws IOException { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + + log.info("[{}] Received POST request to send message to conversation id {}", + user.getLogin(), id.toString()); + + AIMessageDTO aiMessageDTO = new AIMessageToDTOMapper().apply(aiService.sendMessage(user, id, message)); + + return Response.status(HttpStatus.CREATED.value()).entity(aiMessageDTO).build(); + } + + /** + * Handles a GET request to retrieve all messages from a specific AI conversation with optional filtering and + * pagination. + * This endpoint allows users to view the conversation history with AI. + * + * @param request the HttpServletRequest used to access the user's session. + * @param id the ID of the AI conversation from which to retrieve messages. + * @param uriInfo UriInfo object to retrieve query parameters. + * @param queryFilter the filter criteria and pagination information. + * @return a Response object containing a paginated list of AIMessageDTOs. + */ + @GET + @Path("/conversations/{id}/messages") + public Response findAllMessages(final @Context HttpServletRequest request, + final @PathParam("id") @Valid @NotNull UUID id, + final @Context UriInfo uriInfo, + final @BeanParam @Valid QueryFilter queryFilter) { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + Map filters = this.getFilters(uriInfo); + + log.info("[{}] Received GET request to get AI messages with conversation id {} with the following filters: {}", + user.getLogin(), id.toString(), filters); + + Page resources = aiService.findAllMessages(user, id, filters, queryFilter.getPagination()) + .map(new AIMessageToDTOMapper()); + + return Response.status(this.getStatus(resources)).entity(resources).build(); + } } diff --git a/src/main/java/com/ditrit/letomodelizerapi/controller/CurrentUserController.java b/src/main/java/com/ditrit/letomodelizerapi/controller/CurrentUserController.java index 2fb137b2..74712a2d 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/controller/CurrentUserController.java +++ b/src/main/java/com/ditrit/letomodelizerapi/controller/CurrentUserController.java @@ -4,9 +4,11 @@ import com.ditrit.letomodelizerapi.model.BeanMapper; import com.ditrit.letomodelizerapi.model.accesscontrol.AccessControlDTO; import com.ditrit.letomodelizerapi.model.accesscontrol.AccessControlType; +import com.ditrit.letomodelizerapi.model.ai.AIConversationDTO; import com.ditrit.letomodelizerapi.model.permission.PermissionDTO; import com.ditrit.letomodelizerapi.model.user.UserDTO; import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.service.AIService; import com.ditrit.letomodelizerapi.service.AccessControlService; import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; @@ -53,6 +55,10 @@ public class CurrentUserController implements DefaultController { * Service to manage access control. */ private AccessControlService accessControlService; + /** + * Service to manage AI request. + */ + private AIService aiService; /** * The maximum age for caching user pictures. @@ -73,6 +79,7 @@ public class CurrentUserController implements DefaultController { * @param userService the service responsible for managing user details and operations. * @param userPermissionService the service for checking and validating user permissions. * @param accessControlService the service for managing access controls and security. + * @param aiService the service for managing AI request. * @param userPictureCacheMaxAge the configured maximum age for caching user pictures, specified in application * properties, indicating how long client browsers should cache user pictures before * requesting them again. @@ -81,10 +88,12 @@ public class CurrentUserController implements DefaultController { public CurrentUserController(final UserService userService, final UserPermissionService userPermissionService, final AccessControlService accessControlService, + final AIService aiService, final @Value("${user.picture.cache.max.age}") String userPictureCacheMaxAge) { this.userService = userService; this.userPermissionService = userPermissionService; this.accessControlService = accessControlService; + this.aiService = aiService; this.userPictureCacheMaxAge = userPictureCacheMaxAge; } @@ -258,4 +267,41 @@ public Response getMyScopes(final @Context HttpServletRequest request, return Response.status(this.getStatus(resources)).entity(resources).build(); } + + /** + * Retrieves all AI conversations associated to the current user. + * This endpoint provides a paginated list of AI conversations that the current user has, based on the provided + * query filters. + * The method uses the session information from the HttpServletRequest to identify the current user and + * then fetches their AI conversations using the AIService. + * + * @param request HttpServletRequest to access the HTTP session and thereby identify the current user. + * @param uriInfo UriInfo to extract query parameters for additional filtering. + * @param queryFilter BeanParam object for pagination and filtering purposes. + * @return a Response containing a paginated list of AIConversationDTO objects representing the AI conversation of + * the current user. + */ + @GET + @Path("/ai/conversations") + public Response getMyAIConversations(final @Context HttpServletRequest request, + final @Context UriInfo uriInfo, + final @BeanParam @Valid QueryFilter queryFilter) { + HttpSession session = request.getSession(); + User user = userService.getFromSession(session); + Map filters = this.getFilters(uriInfo); + + log.info( + "[{}] Received GET request to get current user AI conversations with the following filters: {}", + user.getLogin(), + filters + ); + + Page resources = aiService.findAll( + user, + filters, + queryFilter.getPagination() + ).map(new BeanMapper<>(AIConversationDTO.class)); + + return Response.status(this.getStatus(resources)).entity(resources).build(); + } } diff --git a/src/main/java/com/ditrit/letomodelizerapi/controller/UserController.java b/src/main/java/com/ditrit/letomodelizerapi/controller/UserController.java index 971c8b1f..aac73aed 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/controller/UserController.java +++ b/src/main/java/com/ditrit/letomodelizerapi/controller/UserController.java @@ -6,9 +6,11 @@ import com.ditrit.letomodelizerapi.model.accesscontrol.AccessControlDTO; import com.ditrit.letomodelizerapi.model.accesscontrol.AccessControlDirectDTO; import com.ditrit.letomodelizerapi.model.accesscontrol.AccessControlType; +import com.ditrit.letomodelizerapi.model.ai.AIConversationDTO; import com.ditrit.letomodelizerapi.model.permission.PermissionDTO; import com.ditrit.letomodelizerapi.model.user.UserDTO; import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.service.AIService; import com.ditrit.letomodelizerapi.service.AccessControlService; import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; @@ -58,6 +60,11 @@ public class UserController implements DefaultController { */ private AccessControlService accessControlService; + /** + * Service to manage access controls. + */ + private AIService aiService; + /** * The maximum age for caching user pictures. * This value is typically specified in application properties and injected into the controller or service @@ -74,16 +81,19 @@ public class UserController implements DefaultController { * @param userService the service responsible for user-related operations. * @param userPermissionService the service for checking user permissions. * @param accessControlService the service for managing access controls. + * @param aiService the service for managing AI functionality. * @param userPictureCacheMaxAge the maximum age for caching user pictures, injected from application properties. */ @Autowired public UserController(final UserService userService, final UserPermissionService userPermissionService, final AccessControlService accessControlService, + final AIService aiService, final @Value("${user.picture.cache.max.age}") String userPictureCacheMaxAge) { this.userService = userService; this.userPermissionService = userPermissionService; this.accessControlService = accessControlService; + this.aiService = aiService; this.userPictureCacheMaxAge = userPictureCacheMaxAge; } @@ -323,6 +333,50 @@ public Response getScopesOfUser(final @Context HttpServletRequest request, return Response.status(this.getStatus(resources)).entity(resources).build(); } + /** + * Retrieves scopes associated with a user identified by their login. + * + *

This method processes a GET request to fetch scopes assigned to a user, utilizing the user's login as the + * identifier. It supports filtering based on query parameters and pagination. The operation requires administrative + * privileges to execute, as it involves accessing sensitive user role information. + * + * @param request the HttpServletRequest from which to obtain the HttpSession for user validation. + * @param login the login identifier of the user whose scopes are being requested. Must be a valid, non-blank + * String. + * @param uriInfo UriInfo context to extract query parameters for filtering results. + * @param queryFilter bean parameter encapsulating filtering and pagination criteria. + * @return a Response object containing the requested page of AccessControlDTO objects representing the scopes + * associated with the specified user. The response status varies based on the outcome of the request. + */ + @GET + @Path("/{login}/ai/conversations") + public Response getAIConversationsOfUser(final @Context HttpServletRequest request, + final @PathParam(Constants.DEFAULT_USER_PROPERTY) @Valid @NotBlank String login, + final @Context UriInfo uriInfo, + final @BeanParam @Valid QueryFilter queryFilter) { + HttpSession session = request.getSession(); + User me = userService.getFromSession(session); + + Map filters = this.getFilters(uriInfo); + log.info( + "[{}] Received GET request to get AI conversations of user {} with the following filters: {}", + me.getLogin(), + login, + filters + ); + + User user = userService.findByLogin(login); + + if (!me.getId().equals(user.getId())) { + userPermissionService.checkIsAdmin(me, null); + } + + Page resources = aiService.findAll(user, filters, queryFilter.getPagination()) + .map(new BeanMapper<>(AIConversationDTO.class)); + + return Response.status(this.getStatus(resources)).entity(resources).build(); + } + /** * Retrieves the profile picture of a user identified by their login. Access to this endpoint is restricted to * users with administrative privileges. The method fetches the picture from the user service and returns it diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationDTO.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationDTO.java new file mode 100644 index 00000000..14510e4b --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationDTO.java @@ -0,0 +1,40 @@ +package com.ditrit.letomodelizerapi.model.ai; + +import lombok.Data; + +import java.sql.Timestamp; +import java.util.UUID; + +/** + * Data Transfer Object (DTO) for AIConversation. + * This class is used for transferring AIConversation data between different layers of the application, + * typically between services and controllers. It is designed to encapsulate the data attributes of an AIConversation + * entity in a form that is easy to serialize and deserialize when sending responses or requests. + */ +@Data +public class AIConversationDTO { + /** + * The unique identifier of the AIConversation entity. + * This field represents the primary key in the database. + */ + private UUID id; + /** + * The conversation key of the AIConversation entity. + */ + private String key; + + /** + * Checksum of last diagram files sent. + */ + private String checksum; + + /** + * Checksum of last diagram files sent. + */ + private Long size; + + /** + * The last update date of the conversation. + */ + private Timestamp updateDate; +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationRecord.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationRecord.java new file mode 100644 index 00000000..8e09e466 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConversationRecord.java @@ -0,0 +1,25 @@ +package com.ditrit.letomodelizerapi.model.ai; + +import com.ditrit.letomodelizerapi.model.file.FileRecord; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +/** + * A record representing the data required to create a new conversation with AI. + * + * @param project the name of the project associated with the conversation. Cannot be blank. + * @param diagram the path to the diagram related to the conversation. Cannot be blank. + * @param plugin the name of the plugin involved in the conversation. Cannot be blank. + * @param checksum the checksum of the diagram files for ensuring data integrity. May be null. + * @param files a list of all diagram files associated with the conversation. Cannot be null. + */ +public record AIConversationRecord( + @NotBlank String project, + @NotBlank String diagram, + @NotBlank String plugin, + String checksum, + @NotNull List files +) { +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIRequestRecord.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AICreateFileRecord.java similarity index 96% rename from src/main/java/com/ditrit/letomodelizerapi/model/ai/AIRequestRecord.java rename to src/main/java/com/ditrit/letomodelizerapi/model/ai/AICreateFileRecord.java index aee3cd9d..7ba81ef6 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIRequestRecord.java +++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AICreateFileRecord.java @@ -14,7 +14,7 @@ * to ensure that only valid request types are considered. * @param description a description of the AI request, providing context or additional information. Must not be blank. */ -public record AIRequestRecord( +public record AICreateFileRecord( @NotBlank String plugin, @Pattern(regexp = "diagram") String type, @NotBlank String description diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIMessageDTO.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIMessageDTO.java new file mode 100644 index 00000000..6fee493c --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIMessageDTO.java @@ -0,0 +1,40 @@ +package com.ditrit.letomodelizerapi.model.ai; + +import lombok.Data; + +import java.sql.Timestamp; +import java.util.UUID; + +/** + * Data Transfer Object (DTO) for AIMessage. + * This class is used for transferring AIMessage data between different layers of the application, + * typically between services and controllers. It is designed to encapsulate the data attributes of an AIMessage + * entity in a form that is easy to serialize and deserialize when sending responses or requests. + */ +@Data +public class AIMessageDTO { + /** + * The unique identifier of the AIConversation entity. + * This field represents the primary key in the database. + */ + private UUID id; + /** + * The conversation uuid of the AIConversation entity. + */ + private UUID aiConversation; + + /** + * Indicate the author of the message: if true, the message is from the user; otherwise, it is from the AI. + */ + private Boolean isUser; + + /** + * The message. + */ + private String message; + + /** + * The last update date of the message. + */ + private Timestamp updateDate; +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/file/FileRecord.java b/src/main/java/com/ditrit/letomodelizerapi/model/file/FileRecord.java new file mode 100644 index 00000000..21ae387e --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/file/FileRecord.java @@ -0,0 +1,15 @@ +package com.ditrit.letomodelizerapi.model.file; + +import jakarta.validation.constraints.NotBlank; + +/** + * A record representing a file. + * + * @param path the file path. Cannot be blank. + * @param content the content of the file. Cannot be blank. + */ +public record FileRecord( + @NotBlank String path, + @NotBlank String content +) { +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/file/package-info.java b/src/main/java/com/ditrit/letomodelizerapi/model/file/package-info.java new file mode 100644 index 00000000..cef2ba92 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/file/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing all models related to File. + */ +package com.ditrit.letomodelizerapi.model.file; diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapper.java b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapper.java new file mode 100644 index 00000000..203498a2 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapper.java @@ -0,0 +1,19 @@ +package com.ditrit.letomodelizerapi.model.mapper.ai; + +import com.ditrit.letomodelizerapi.model.BeanMapper; +import com.ditrit.letomodelizerapi.model.ai.AIMessageDTO; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; + +import java.util.Base64; +import java.util.function.Function; + +public class AIMessageToDTOMapper implements Function { + @Override + public AIMessageDTO apply(final AIMessage aiMessage) { + AIMessageDTO aiMessageDTO = new BeanMapper<>(AIMessageDTO.class, "message").apply(aiMessage); + + aiMessageDTO.setMessage(Base64.getEncoder().encodeToString(aiMessage.getMessage())); + + return aiMessageDTO; + } +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/package-info.java b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/package-info.java new file mode 100644 index 00000000..d96051d5 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/ai/package-info.java @@ -0,0 +1,4 @@ +/** + * Package that contains all mapper of AI models. + */ +package com.ditrit.letomodelizerapi.model.mapper.ai; diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/mapper/package-info.java b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/package-info.java new file mode 100644 index 00000000..2c04022c --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/model/mapper/package-info.java @@ -0,0 +1,4 @@ +/** + * Package that contains all mapper of models. + */ +package com.ditrit.letomodelizerapi.model.mapper; diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConversation.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConversation.java new file mode 100644 index 00000000..2a443d7e --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConversation.java @@ -0,0 +1,78 @@ +package com.ditrit.letomodelizerapi.persistence.model; + +import com.ditrit.letomodelizerapi.persistence.specification.filter.FilterType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing an AI conversation associated with a user. + * This class maps to the 'ai_conversations' table in the database, storing AI conversations that are used + * to register all conversation with AI for a user. + */ +@Entity +@Table(name = "ai_conversations") +@EqualsAndHashCode(callSuper = true) +@Data +public class AIConversation extends AbstractEntity { + + /** + * Internal id. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "aic_id") + @FilterType(type = FilterType.Type.UUID) + private UUID id; + + /** + * The login identifier of the user to whom the conversation is associated. + */ + @Column(name = "usr_id") + @FilterType(type = FilterType.Type.UUID) + private UUID userId; + + /** + * The conversation key. + */ + @Column(name = "key") + @FilterType(type = FilterType.Type.TEXT) + private String key; + + /** + * Checksum of last diagram files sent. + */ + @Column(name = "checksum") + private String checksum; + + /** + * The conversation context for AI. + */ + @Column(name = "context") + private String context; + + /** + * Size of all conversation messages. + */ + @Column(name = "size") + @FilterType(type = FilterType.Type.NUMBER) + private Long size; + + /** + * Set insertDate before persisting in repository. + */ + @PrePersist + public void prePersist() { + this.setInsertDate(Timestamp.valueOf(LocalDateTime.now())); + } +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIMessage.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIMessage.java new file mode 100644 index 00000000..fe1a231b --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIMessage.java @@ -0,0 +1,63 @@ +package com.ditrit.letomodelizerapi.persistence.model; + +import com.ditrit.letomodelizerapi.persistence.specification.filter.FilterType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing an AI message associated with an AI conversation. + * This class maps to the 'ai_messages' table in the database, storing message between AI and user. + */ +@Entity +@Table(name = "ai_messages") +@EqualsAndHashCode(callSuper = true) +@Data +public class AIMessage extends AbstractEntity { + + /** + * Internal id. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "aim_id") + @FilterType(type = FilterType.Type.UUID) + private UUID id; + + /** + * The identifier of the conversation. + */ + @Column(name = "aic_id") + @FilterType(type = FilterType.Type.UUID) + private UUID aiConversation; + + /** + * Indicate the author of the message: if true, the message is from the user; otherwise, it is from the AI. + */ + @Column(name = "is_user") + private Boolean isUser; + + /** + * Base64 of compressed message. + */ + @Column(name = "message") + private byte[] message; + + /** + * Set insertDate before persisting in repository. + */ + @PrePersist + public void prePersist() { + this.setInsertDate(Timestamp.valueOf(LocalDateTime.now())); + } +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AbstractEntity.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AbstractEntity.java index 69dec3b6..d5cfbdd5 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AbstractEntity.java +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AbstractEntity.java @@ -1,6 +1,7 @@ package com.ditrit.letomodelizerapi.persistence.model; +import com.ditrit.letomodelizerapi.persistence.specification.filter.FilterType; import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; import jakarta.persistence.Version; @@ -19,12 +20,14 @@ public class AbstractEntity { * The creation date of this entity. */ @Column(name = "insert_date", updatable = false) + @FilterType(type = FilterType.Type.DATE) private Timestamp insertDate; /** * The last update date of this entity. */ @Column(name = "update_date") + @FilterType(type = FilterType.Type.DATE) @Version private Timestamp updateDate; } diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConversationRepository.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConversationRepository.java new file mode 100644 index 00000000..5216cfd2 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConversationRepository.java @@ -0,0 +1,43 @@ +package com.ditrit.letomodelizerapi.persistence.repository; + +import com.ditrit.letomodelizerapi.persistence.model.AIConversation; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Spring Data JPA repository interface for AIConversation entities. + * This interface extends JpaRepository, providing standard CRUD operations for AIConversation entities. + */ +public interface AIConversationRepository extends JpaRepository { + /** + * Checks if a AIConversation with the specified user Id and key already exists in the database. + * + * @param userId the userId to check for existence + * @param key the key to check for existence + * @return true if a AIConversation with the user Id and key exists, false otherwise + */ + boolean existsByUserIdAndKey(UUID userId, String key); + + /** + * Finds an AIConversation by its id and userId. + * + * @param id the id of the AIConversation. + * @param userId the userId associated with the AIConversation. + * @return an Optional containing the AIConversation if found, or an empty Optional if not found. + */ + Optional findByIdAndUserId(UUID id, UUID userId); + + /** + * Finds all AIConversations that match the provided specification, with pagination support. + * + * @param specification the specification to filter AIConversations. + * @param pageable the pagination information. + * @return a Page containing the AIConversations that match the specification. + */ + Page findAll(Specification specification, Pageable pageable); +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIMessageRepository.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIMessageRepository.java new file mode 100644 index 00000000..ebf49b02 --- /dev/null +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIMessageRepository.java @@ -0,0 +1,24 @@ +package com.ditrit.letomodelizerapi.persistence.repository; + +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +/** + * Spring Data JPA repository interface for AIMessage entities. + * This interface extends JpaRepository, providing standard CRUD operations for AIMessage entities. + */ +public interface AIMessageRepository extends JpaRepository { + /** + * Finds all AIMessages that match the provided specification, with pagination support. + * + * @param specification the specification to filter AIMessages. + * @param pageable the pagination information. + * @return a Page containing the AIMessages that match the specification. + */ + Page findAll(Specification specification, Pageable pageable); +} diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilter.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilter.java index 4fe388d5..942a8edf 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilter.java +++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilter.java @@ -21,11 +21,11 @@ public class DatePredicateFilter extends PredicateFilter { /** * Default date format for date filter. */ - public static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; + public static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS"; /** * Regex pattern for date format. */ - private static final String FILTER_SUB_PATTERN = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"; + private static final String FILTER_SUB_PATTERN = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}"; /** * Regex to extract date. *

diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java index ca1f1143..7f3cf416 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java +++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java @@ -1,11 +1,21 @@ package com.ditrit.letomodelizerapi.service; -import com.ditrit.letomodelizerapi.model.ai.AIRequestRecord; +import com.ditrit.letomodelizerapi.model.ai.AIConversationRecord; +import com.ditrit.letomodelizerapi.model.ai.AICreateFileRecord; +import com.ditrit.letomodelizerapi.persistence.model.AIConversation; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; +import com.ditrit.letomodelizerapi.persistence.model.User; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; /** * Service implementation for interacting with an Artificial Intelligence (AI). - * This class provides concrete implementation for sending requests to an AI, based on user input and specific - * request details encapsulated in an AIRequestRecord. + * This class provides concrete implementation for sending requests to an AI and based on user input. */ public interface AIService { @@ -16,5 +26,95 @@ public interface AIService { * @param aiRequest the AIRequestRecord containing details about the request to be sent to the AI. * @return the response from the AI as a String. */ - String sendRequest(AIRequestRecord aiRequest); + String createFile(AICreateFileRecord aiRequest); + + /** + * Creates a new AI conversation based on the provided user and conversation details. + * + * @param user the user initiating the conversation. + * @param aiConversationRecord an instance of AIConversationRecord containing the details of the conversation. + * @return the newly created AIConversation. + * @throws JsonProcessingException if there is an error processing the JSON data. + */ + AIConversation createConversation(User user, AIConversationRecord aiConversationRecord) + throws JsonProcessingException; + + /** + * Retrieves an AI conversation by its ID for the specified user. + * + * @param user the user requesting the conversation. + * @param id the ID of the conversation to retrieve. + * @return the AIConversation with the specified ID. + */ + AIConversation getConversationById(User user, UUID id); + + /** + * Updates an existing AI conversation by its ID for the specified user. + * + * @param user the user requesting the update. + * @param id the ID of the conversation to update. + * @param aiConversationRecord an instance of AIConversationRecord containing the updated conversation details. + * @return the updated AIConversation. + * @throws JsonProcessingException if there is an error processing the JSON data. + */ + AIConversation updateConversationById(User user, UUID id, AIConversationRecord aiConversationRecord) + throws JsonProcessingException; + + /** + * Deletes an AI conversation by its ID. + * + * @param id the ID of the conversation to delete. + */ + void deleteConversationById(UUID id); + + /** + * Deletes an AI conversation by its ID for the specified user. + * + * @param user the user requesting the deletion. + * @param id the ID of the conversation to delete. + */ + void deleteConversationById(User user, UUID id); + + /** + * Sends a message to an AI conversation. + * + * @param user the user sending the message. + * @param id the ID of the conversation to which the message is sent. + * @param message the content of the message to be sent. + * @return the AI's response to the message. + * @throws JsonProcessingException if there is an error processing the JSON data. + */ + AIMessage sendMessage(User user, UUID id, String message) throws IOException; + + /** + * Finds all AI conversations for a user with optional filtering and pagination. + * + * @param user the user for whom to find conversations. + * @param filters a map of filters to apply to the search. + * @param pageable pagination information. + * @return a Page containing the AI conversations matching the filters. + */ + Page findAll(User user, Map filters, Pageable pageable); + + /** + * Finds all messages for a specific AI conversation with optional filtering and pagination. + * + * @param user the user requesting the messages. + * @param id the ID of the conversation for which to find messages. + * @param filters a map of filters to apply to the search. + * @param pageable pagination information. + * @return a Page containing the messages for the specified conversation. + */ + Page findAllMessages(User user, UUID id, Map filters, Pageable pageable); + + /** + * Finds all AI conversations with optional filtering and pagination. + * + * @param immutableFilters a map of immutable filters to apply to the search. + * @param pageable pagination information. + * @return a Page containing all AI conversations matching the filters. + */ + Page findAllConversations(Map immutableFilters, + Pageable pageable); + } diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java index a0b538a9..adc96810 100644 --- a/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java +++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java @@ -1,24 +1,49 @@ package com.ditrit.letomodelizerapi.service; +import com.ditrit.letomodelizerapi.config.Constants; +import com.ditrit.letomodelizerapi.model.ai.AIConversationRecord; +import com.ditrit.letomodelizerapi.model.ai.AICreateFileRecord; import com.ditrit.letomodelizerapi.model.error.ApiException; import com.ditrit.letomodelizerapi.model.error.ErrorType; -import com.ditrit.letomodelizerapi.model.ai.AIRequestRecord; +import com.ditrit.letomodelizerapi.model.file.FileRecord; +import com.ditrit.letomodelizerapi.persistence.model.AIConversation; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; +import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.persistence.repository.AIConversationRepository; +import com.ditrit.letomodelizerapi.persistence.repository.AIMessageRepository; +import com.ditrit.letomodelizerapi.persistence.specification.SpecificationHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.transaction.Transactional; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; /** * Service implementation for interacting with an Artificial Intelligence (AI). @@ -27,6 +52,7 @@ */ @Slf4j @Service +@Transactional public class AIServiceImpl implements AIService { /** @@ -34,6 +60,20 @@ public class AIServiceImpl implements AIService { */ private final String aiHost; + /** + * The AIConversationRepository instance is injected by Spring's dependency injection mechanism. + * This repository is used for performing database operations related to AIConversation entities, such as querying, + * saving and updating user data. + */ + private final AIConversationRepository aiConversationRepository; + + /** + * The AIConversationRepository instance is injected by Spring's dependency injection mechanism. + * This repository is used for performing database operations related to AIConversation entities, such as querying, + * saving and updating user data. + */ + private final AIMessageRepository aiMessageRepository; + /** * Constructor for AIServiceImpl. * Initializes the service with the host address of the Artificial Intelligence (AI) system. This address is used to @@ -42,26 +82,31 @@ public class AIServiceImpl implements AIService { * The host address is injected from the application's configuration properties, allowing for flexible deployment * and configuration of the AI service endpoint. * + * @param aiConversationRepository Repository to manage AIConversation. + * @param aiMessageRepository Repository to manage AIMessage. * @param aiHost the host address of the Artificial Intelligence system, injected from application properties. */ @Autowired - public AIServiceImpl(@Value("${ai.host}") final String aiHost) { + public AIServiceImpl(final AIConversationRepository aiConversationRepository, + final AIMessageRepository aiMessageRepository, + @Value("${ai.host}") final String aiHost) { + this.aiConversationRepository = aiConversationRepository; + this.aiMessageRepository = aiMessageRepository; this.aiHost = aiHost; } - @Override - public String sendRequest(final AIRequestRecord aiRequest) { - String url = String.format("%s%s/", aiHost, aiRequest.type()); - - ObjectNode json = JsonNodeFactory.instance.objectNode(); - json.put("pluginName", aiRequest.plugin()); - json.put("description", aiRequest.description()); - - String body = json.toString(); - + /** + * Sends a request to the AI service with the specified endpoint and request body. + * + * @param endpoint the URL of the AI endpoint to which the request is sent. + * @param body the content to be sent in the body of the request. + * @return the response body returned by the AI service. + */ + public String sendRequest(final String endpoint, final String body) { try { + URI uri = new URI(aiHost).resolve("api/").resolve(endpoint); HttpRequest request = HttpRequest.newBuilder() - .uri(new URI(url)) + .uri(uri) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) .version(HttpClient.Version.HTTP_1_1) @@ -78,16 +123,217 @@ public String sendRequest(final AIRequestRecord aiRequest) { } if (!HttpStatus.valueOf(response.statusCode()).is2xxSuccessful()) { - throw new ApiException(ErrorType.WRONG_VALUE, "url", url); + throw new ApiException(ErrorType.WRONG_VALUE, "url", uri.toString()); } return response.body(); } catch (URISyntaxException | IOException e) { - throw new ApiException(ErrorType.WRONG_VALUE, "url", url); + throw new ApiException(ErrorType.WRONG_VALUE, "url", aiHost + "api/" + endpoint); } catch (InterruptedException e) { - log.warn("InterruptedException during requesting ai with {} and ", url, body, e); + log.warn("InterruptedException during requesting ai with {} and {}", aiHost + "api/" + endpoint, body, e); Thread.currentThread().interrupt(); - throw new ApiException(ErrorType.INTERNAL_ERROR, "url", url); + throw new ApiException(ErrorType.INTERNAL_ERROR, "url", aiHost + "api/" + endpoint); + } + } + + /** + * Sends files to the AI service with the specified conversation. + * + * @param conversation The conversation. + * @param files Files to send. + * @return The context returned by the AI response. + */ + public String sendFiles(final AIConversation conversation, final List files) + throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + ArrayNode arrayNode = mapper.createArrayNode(); + + files.forEach(file -> { + ObjectNode fileNode = mapper.createObjectNode(); + + fileNode.put("path", file.path()); + fileNode.put("content", file.content()); + + arrayNode.add(fileNode); + }); + + ObjectNode json = JsonNodeFactory.instance.objectNode(); + json.put("pluginName", conversation.getKey().split("/")[2]); + json.set("files", arrayNode); + + JsonNode response = mapper.readTree(sendRequest("chat", json.toString())); + + return response.get(Constants.DEFAULT_CONTEXT_PROPERTY).asText(); + } + + /** + * Compress message with GZIP. + * @param message Message to compress. + * @return Compressed message. + * @throws IOException If an I/O error has occurred. + */ + public byte[] compress(final String message) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(outputStream); + + gzip.write(message.getBytes(StandardCharsets.UTF_8)); + gzip.close(); + + return outputStream.toByteArray(); + } + + @Override + public String createFile(final AICreateFileRecord createFileRecord) { + ObjectNode json = JsonNodeFactory.instance.objectNode(); + json.put("pluginName", createFileRecord.plugin()); + json.put("description", createFileRecord.description()); + + return sendRequest(createFileRecord.type(), json.toString()); + } + + @Override + public AIConversation createConversation(final User user, final AIConversationRecord aiConversationRecord) + throws JsonProcessingException { + String key = String.format("%s/%s/%s", + aiConversationRecord.project(), aiConversationRecord.diagram(), aiConversationRecord.plugin()); + if (aiConversationRepository.existsByUserIdAndKey(user.getId(), key)) { + throw new ApiException(ErrorType.ENTITY_ALREADY_EXISTS, "key", key); } + + AIConversation conversation = new AIConversation(); + conversation.setUserId(user.getId()); + conversation.setKey(key); + conversation.setChecksum(aiConversationRecord.checksum()); + conversation.setSize(0L); + + String context = sendFiles(conversation, aiConversationRecord.files()); + + conversation.setContext(context); + + return aiConversationRepository.save(conversation); + } + + @Override + public AIConversation getConversationById(final User user, final UUID id) { + return aiConversationRepository.findByIdAndUserId(id, user.getId()) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + } + + @Override + public AIConversation updateConversationById(final User user, + final UUID id, + final AIConversationRecord aiConversationRecord) + throws JsonProcessingException { + AIConversation conversation = aiConversationRepository.findByIdAndUserId(id, user.getId()) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + + String context = sendFiles(conversation, aiConversationRecord.files()); + + conversation.setChecksum(aiConversationRecord.checksum()); + conversation.setContext(context); + + return aiConversationRepository.save(conversation); + } + + @Override + public void deleteConversationById(final UUID id) { + AIConversation conversation = aiConversationRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + + aiConversationRepository.delete(conversation); + } + + @Override + public void deleteConversationById(final User user, final UUID id) { + AIConversation conversation = aiConversationRepository.findByIdAndUserId(id, user.getId()) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + + aiConversationRepository.delete(conversation); + } + + @Override + public AIMessage sendMessage(final User user, final UUID id, final String message) throws IOException { + AIConversation conversation = aiConversationRepository.findByIdAndUserId(id, user.getId()) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + + byte[] compressedMessage = compress(message); + Long size = conversation.getSize() + compressedMessage.length; + + AIMessage userMessage = new AIMessage(); + userMessage.setAiConversation(conversation.getId()); + userMessage.setIsUser(true); + userMessage.setMessage(compressedMessage); + aiMessageRepository.save(userMessage); + + ObjectNode json = JsonNodeFactory.instance.objectNode(); + json.put(Constants.DEFAULT_CONTEXT_PROPERTY, conversation.getContext()); + json.put("message", message); + + JsonNode response = new ObjectMapper().readTree(sendRequest("chat", json.toString())); + + compressedMessage = compress(response.get("message").asText()); + size += compressedMessage.length; + + AIMessage aiMessage = new AIMessage(); + aiMessage.setAiConversation(conversation.getId()); + aiMessage.setIsUser(false); + aiMessage.setMessage(compressedMessage); + aiMessage = aiMessageRepository.save((aiMessage)); + + conversation.setContext(response.get(Constants.DEFAULT_CONTEXT_PROPERTY).asText()); + conversation.setSize(size); + aiConversationRepository.save(conversation); + + return aiMessage; + } + + @Override + public Page findAll(final User user, + final Map immutableFilters, + final Pageable pageable) { + Map filters = new HashMap<>(immutableFilters); + filters.put("userId", user.getId().toString()); + + return aiConversationRepository.findAll( + new SpecificationHelper<>(AIConversation.class, filters), + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + pageable.getSortOr(Sort.by(Sort.Direction.DESC, Constants.DEFAULT_UPDATE_DATE_PROPERTY)) + ) + ); + } + + @Override + public Page findAllMessages(final User user, + final UUID id, + final Map immutableFilters, + final Pageable pageable) { + AIConversation conversation = aiConversationRepository.findByIdAndUserId(id, user.getId()) + .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString())); + + Map filters = new HashMap<>(immutableFilters); + filters.put("aiConversation", conversation.getId().toString()); + + return aiMessageRepository.findAll( + new SpecificationHelper<>(AIMessage.class, filters), + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + pageable.getSortOr(Sort.by(Sort.Direction.DESC, Constants.DEFAULT_UPDATE_DATE_PROPERTY))) + ); + } + + @Override + public Page findAllConversations(final Map immutableFilters, + final Pageable pageable) { + + return aiConversationRepository.findAll( + new SpecificationHelper<>(AIConversation.class, immutableFilters), + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + pageable.getSortOr(Sort.by(Sort.Direction.DESC, Constants.DEFAULT_UPDATE_DATE_PROPERTY)) + )); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ce1078b4..0de409cb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,6 +6,7 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.generate-ddl=false spring.jpa.hibernate.ddl-auto=none +spring.jpa.properties.hibernate.jdbc.time_zone=UTC spring.flyway.baseline-on-migrate=true spring.flyway.out-of-order=true @@ -41,4 +42,4 @@ library.files.cache.max.age=${LIBRARY_FILES_CACHE_MAX_AGE:86400} user.picture.cache.max.age=${USER_PICTURE_CACHE_MAX_AGE:604800} csrf.token.timeout=${CSRF_TOKEN_TIMEOUT:3600} server.servlet.session.timeout=${USER_SESSION_TIMEOUT:3600} -ai.host=${AI_HOST} +ai.host=${AI_HOST:http://localhost:8585/} diff --git a/src/main/resources/db/migration/V1_10_0__new_table_ai_messages.sql b/src/main/resources/db/migration/V1_10_0__new_table_ai_messages.sql new file mode 100644 index 00000000..3dd6058f --- /dev/null +++ b/src/main/resources/db/migration/V1_10_0__new_table_ai_messages.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS ai_messages ( + aim_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + aic_id UUID REFERENCES ai_conversations(aic_id) ON DELETE CASCADE NOT NULL, + is_user BOOLEAN NOT NULL, + message bytea, + insert_date TIMESTAMP NOT NULL DEFAULT now(), + update_date TIMESTAMP NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ai_messages IS 'Table to store AI chat messages.'; +COMMENT ON COLUMN ai_messages.aic_id IS 'It indicates the conversation for the current entry. References the primary key in the ai_conversations table.'; +COMMENT ON COLUMN ai_messages.is_user IS 'Indicate the author of the message: if true, the message is from the user; otherwise, it is from the AI.'; +COMMENT ON COLUMN ai_messages.message IS 'Base64 of compressed message.'; +COMMENT ON COLUMN ai_messages.insert_date IS 'Creation date of this row.'; +COMMENT ON COLUMN ai_messages.update_date IS 'Last update date of this row.'; diff --git a/src/main/resources/db/migration/V1_9_0__new_table_ai_conversations.sql b/src/main/resources/db/migration/V1_9_0__new_table_ai_conversations.sql new file mode 100644 index 00000000..69637acc --- /dev/null +++ b/src/main/resources/db/migration/V1_9_0__new_table_ai_conversations.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS ai_conversations ( + aic_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + usr_id UUID REFERENCES users(usr_id) ON DELETE CASCADE NOT NULL, + key TEXT NOT NULL, + checksum TEXT, + context TEXT, + size BIGINT, + insert_date TIMESTAMP NOT NULL DEFAULT now(), + update_date TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uc_aic_usr_id_key UNIQUE (usr_id, key) +); + +COMMENT ON TABLE ai_conversations IS 'Table to store AI chat conversation.'; +COMMENT ON COLUMN ai_conversations.aic_id IS 'Primary key, serial.'; +COMMENT ON COLUMN ai_conversations.usr_id IS 'It indicates the user for the current entry. References the primary key in the users table.'; +COMMENT ON COLUMN ai_conversations.key IS 'Concatenation of project name, diagram path and plugin name.'; +COMMENT ON COLUMN ai_conversations.checksum IS 'Checksum of last diagram files sent.'; +COMMENT ON COLUMN ai_conversations.context IS 'AI context of conversation.'; +COMMENT ON COLUMN ai_conversations.size IS 'Size of all conversation messages.'; +COMMENT ON COLUMN ai_conversations.insert_date IS 'Creation date of this row.'; +COMMENT ON COLUMN ai_conversations.update_date IS 'Last update date of this row.'; + +COMMENT ON CONSTRAINT uc_aic_usr_id_key ON ai_conversations IS 'Last update date of this row.'; diff --git a/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java b/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java index a02741fe..43d877af 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java @@ -1,10 +1,16 @@ package com.ditrit.letomodelizerapi.controller; +import com.ditrit.letomodelizerapi.controller.model.QueryFilter; import com.ditrit.letomodelizerapi.helper.MockHelper; -import com.ditrit.letomodelizerapi.model.ai.AIRequestRecord; +import com.ditrit.letomodelizerapi.model.ai.AIConversationRecord; +import com.ditrit.letomodelizerapi.model.ai.AICreateFileRecord; +import com.ditrit.letomodelizerapi.persistence.model.AIConversation; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; import com.ditrit.letomodelizerapi.persistence.model.User; import com.ditrit.letomodelizerapi.service.AIService; +import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; +import com.fasterxml.jackson.core.JsonProcessingException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.ws.rs.core.Response; @@ -16,8 +22,13 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; +import java.io.IOException; +import java.util.List; +import java.util.UUID; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -29,17 +40,19 @@ class AIControllerTest extends MockHelper { @Mock UserService userService; @Mock - AIService service; + UserPermissionService userPermissionService; + @Mock + AIService aiService; @InjectMocks AIController controller; @Test - @DisplayName("Test requestAI: should return valid response.") - void testRequestAI() throws InterruptedException { + @DisplayName("Test generateFiles: should return valid response.") + void testGenerateFiles() { User user = new User(); user.setLogin("login"); - AIRequestRecord aiRequestRecord = new AIRequestRecord("@ditrit/kubernator-plugin", "diagram", "I want a sample of kubernetes code"); + AICreateFileRecord aiCreateFileRecord = new AICreateFileRecord("@ditrit/kubernator-plugin", "diagram", "I want a sample of kubernetes code"); Mockito .when(userService.getFromSession(Mockito.any())) @@ -50,12 +63,184 @@ void testRequestAI() throws InterruptedException { .when(request.getSession()) .thenReturn(session); - Mockito.when(this.service.sendRequest(aiRequestRecord)).thenReturn("OK"); - final Response response = this.controller.requestAI(request, aiRequestRecord); + Mockito.when(aiService.createFile(aiCreateFileRecord)).thenReturn("OK"); + final Response response = this.controller.generateFiles(request, aiCreateFileRecord); assertNotNull(response); assertEquals(HttpStatus.CREATED.value(), response.getStatus()); assertNotNull(response.getEntity()); } + @Test + @DisplayName("Test createConversations: should return valid response.") + void testCreateConversations() throws JsonProcessingException { + User user = new User(); + user.setLogin("login"); + AIConversationRecord aiConversationRecord = new AIConversationRecord( + "project", + "diagram", + "@ditrit/kubernator-plugin", + "checksum", + List.of()); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(aiService.createConversation(user, aiConversationRecord)).thenReturn(new AIConversation()); + + Response response = this.controller.createConversations(request, aiConversationRecord); + + assertNotNull(response); + assertEquals(HttpStatus.CREATED.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test findAllConversations: should return valid response.") + void testFindAllConversations() { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.doNothing().when(userPermissionService).checkIsAdmin(Mockito.any(), Mockito.any()); + Mockito.when(aiService.findAllConversations(Mockito.any(), Mockito.any())).thenReturn(Page.empty()); + + Response response = this.controller.findAllConversations(request, mockUriInfo(), new QueryFilter()); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test getConversationById: should return valid response.") + void testGetConversationById() { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(aiService.getConversationById(Mockito.any(), Mockito.any())).thenReturn(new AIConversation()); + + Response response = this.controller.getConversationById(request, UUID.randomUUID()); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test updateConversationById: should return valid response.") + void testUpdateConversationById() throws JsonProcessingException { + User user = new User(); + user.setLogin("login"); + AIConversationRecord aiConversationRecord = new AIConversationRecord( + "project", + "diagram", + "plugin", + "checksum", + List.of() + ); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(aiService.updateConversationById(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(new AIConversation()); + + Response response = this.controller.updateConversationById(request, UUID.randomUUID(), aiConversationRecord); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test deleteConversationById: should return valid response for simple user.") + void testDeleteConversationById() { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(userPermissionService.hasPermission(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(false); + Mockito.doNothing().when(aiService).deleteConversationById(Mockito.any(), Mockito.any()); + + Response response = this.controller.deleteConversationById(request, UUID.randomUUID()); + + assertNotNull(response); + assertEquals(HttpStatus.NO_CONTENT.value(), response.getStatus()); + Mockito.verify(aiService, Mockito.times(0)).deleteConversationById(Mockito.any()); + Mockito.verify(aiService, Mockito.times(1)).deleteConversationById(Mockito.any(), Mockito.any()); + } + + @Test + @DisplayName("Test deleteConversationById: should return valid response for admin.") + void testDeleteConversationByIdAsAdmin() { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(userPermissionService.hasPermission(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true); + Mockito.doNothing().when(aiService).deleteConversationById(Mockito.any()); + + Response response = this.controller.deleteConversationById(request, UUID.randomUUID()); + + assertNotNull(response); + assertEquals(HttpStatus.NO_CONTENT.value(), response.getStatus()); + Mockito.verify(aiService, Mockito.times(1)).deleteConversationById(Mockito.any()); + Mockito.verify(aiService, Mockito.times(0)).deleteConversationById(Mockito.any(), Mockito.any()); + } + + @Test + @DisplayName("Test createConversationMessage: should return valid response.") + void testCreateConversationMessage() throws IOException { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + AIMessage message = new AIMessage(); + message.setMessage("test".getBytes()); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(aiService.sendMessage(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(message); + + Response response = this.controller.createConversationMessage(request, UUID.randomUUID(), "ok"); + + assertNotNull(response); + assertEquals(HttpStatus.CREATED.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test findAllMessages: should return valid response.") + void testFindAllMessages() { + User user = new User(); + user.setLogin("login"); + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(aiService.findAllMessages(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Page.empty()); + + Response response = this.controller.findAllMessages(request, UUID.randomUUID(), mockUriInfo(), new QueryFilter()); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } } diff --git a/src/test/java/com/ditrit/letomodelizerapi/controller/CurrentUserControllerTest.java b/src/test/java/com/ditrit/letomodelizerapi/controller/CurrentUserControllerTest.java index 8b5f878c..20c0722d 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/controller/CurrentUserControllerTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/controller/CurrentUserControllerTest.java @@ -8,6 +8,7 @@ import com.ditrit.letomodelizerapi.model.user.UserDTO; import com.ditrit.letomodelizerapi.persistence.model.Permission; import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.service.AIService; import com.ditrit.letomodelizerapi.service.AccessControlService; import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; @@ -39,7 +40,6 @@ @ExtendWith(MockitoExtension.class) @DisplayName("Test class: CurrentUserController") class CurrentUserControllerTest extends MockHelper { - @Mock UserService userService; @@ -49,8 +49,11 @@ class CurrentUserControllerTest extends MockHelper { @Mock AccessControlService accessControlService; + @Mock + AIService aiService; + @InjectMocks - CurrentUserController controller; + CurrentUserController currentUserController; @Test @DisplayName("Test getMyInformation: should return valid response.") @@ -77,7 +80,7 @@ void testGetMyInformation() { .when(userService.getFromSession(Mockito.any())) .thenReturn(user); - Response response = controller.getMyInformation(request); + Response response = currentUserController.getMyInformation(request); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); @@ -87,7 +90,13 @@ void testGetMyInformation() { @Test @DisplayName("Test getPicture: should return valid response.") void testGetPicture() { - CurrentUserController controller = new CurrentUserController(userService, userPermissionService, accessControlService, "1"); + CurrentUserController controller = new CurrentUserController( + userService, + userPermissionService, + accessControlService, + aiService, + "1" + ); User user = new User(); user.setId(UUID.randomUUID()); user.setEmail("email"); @@ -152,7 +161,7 @@ void testGetMyPermissions() { expectedPermission.setLibraryId(permission.getLibraryId()); List expectedPermissions = List.of(expectedPermission); - Response response = controller.getMyPermissions(request); + Response response = currentUserController.getMyPermissions(request); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); @@ -178,7 +187,7 @@ void testGetMyRoles() { .when(accessControlService.findAll(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(Page.empty()); - Response response = controller.getMyRoles(request, mockUriInfo(), new QueryFilter()); + Response response = currentUserController.getMyRoles(request, mockUriInfo(), new QueryFilter()); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); @@ -204,7 +213,7 @@ void testGetMyGroups() { .when(accessControlService.findAll(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(Page.empty()); - Response response = controller.getMyGroups(request, mockUriInfo(), new QueryFilter()); + Response response = currentUserController.getMyGroups(request, mockUriInfo(), new QueryFilter()); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); @@ -230,7 +239,33 @@ void testGetMyScopes() { .when(accessControlService.findAll(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(Page.empty()); - Response response = controller.getMyScopes(request, mockUriInfo(), new QueryFilter()); + Response response = currentUserController.getMyScopes(request, mockUriInfo(), new QueryFilter()); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test getMyAIConversations: should return valid response.") + void testMyAIConversations() { + User user = new User(); + user.setId(UUID.randomUUID()); + + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + + Mockito + .when(request.getSession()) + .thenReturn(session); + Mockito + .when(userService.getFromSession(Mockito.any())) + .thenReturn(user); + Mockito + .when(aiService.findAll(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Page.empty()); + + Response response = currentUserController.getMyAIConversations(request, mockUriInfo(), new QueryFilter()); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); diff --git a/src/test/java/com/ditrit/letomodelizerapi/controller/UserControllerTest.java b/src/test/java/com/ditrit/letomodelizerapi/controller/UserControllerTest.java index 9937059d..aab62cb3 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/controller/UserControllerTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/controller/UserControllerTest.java @@ -3,6 +3,7 @@ import com.ditrit.letomodelizerapi.controller.model.QueryFilter; import com.ditrit.letomodelizerapi.helper.MockHelper; import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.service.AIService; import com.ditrit.letomodelizerapi.service.AccessControlService; import com.ditrit.letomodelizerapi.service.UserPermissionService; import com.ditrit.letomodelizerapi.service.UserService; @@ -46,6 +47,9 @@ class UserControllerTest extends MockHelper { @Mock AccessControlService accessControlService; + @Mock + AIService aiService; + @InjectMocks UserController controller; @@ -241,7 +245,7 @@ void testGetScopesOfUser() { @Test @DisplayName("Test getPicture: should return valid response.") void testGetPicture() { - UserController controller = new UserController(userService, userPermissionService, accessControlService, "1"); + UserController userController = new UserController(userService, userPermissionService, accessControlService, aiService, "1"); User user = new User(); user.setId(UUID.randomUUID()); user.setEmail("email"); @@ -265,9 +269,61 @@ void testGetPicture() { .when(userService.getPicture(Mockito.any())) .thenReturn(picture); - Response response = controller.getPictureOfUser(request, "test"); + Response response = userController.getPictureOfUser(request, "test"); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + } + + @Test + @DisplayName("Test getAIConversationsOfUser: should return valid response for our user.") + void testGetAIConversationsOfUser() { + UUID id = UUID.randomUUID(); + User user = new User(); + user.setLogin("login"); + user.setId(id); + + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user); + Mockito.when(userService.findByLogin(Mockito.any())).thenReturn(user); + + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getSession()).thenReturn(session); + Mockito.when(this.userService.findByLogin(Mockito.any())).thenReturn(new User()); + Mockito.when(this.aiService.findAll(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Page.empty()); + final Response response = this.controller.getAIConversationsOfUser(request, "login", mockUriInfo(), new QueryFilter()); + + assertNotNull(response); + assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); + } + + @Test + @DisplayName("Test getAIConversationsOfUser: should return valid response for another user.") + void testGetAIConversationsOfUserAnother() { + User me = new User(); + me.setLogin("me"); + me.setId(UUID.randomUUID()); + + User user = new User(); + user.setLogin("login"); + user.setId(UUID.randomUUID()); + + Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(me); + Mockito.when(userService.findByLogin(Mockito.any())).thenReturn(user); + + HttpSession session = Mockito.mock(HttpSession.class); + HttpServletRequest request = Mockito.mock(HttpServletRequest.class); + Mockito.when(request.getSession()).thenReturn(session); + Mockito.doNothing().when(userPermissionService).checkIsAdmin(Mockito.any(), Mockito.any()); + Mockito.when(this.userService.findByLogin(Mockito.any())).thenReturn(new User()); + Mockito.when(this.aiService.findAll(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Page.empty()); + final Response response = this.controller.getAIConversationsOfUser(request, "login", mockUriInfo(), new QueryFilter()); assertNotNull(response); assertEquals(HttpStatus.OK.value(), response.getStatus()); + assertNotNull(response.getEntity()); } } diff --git a/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java b/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java index 0a44cd5f..6441bdfb 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java +++ b/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java @@ -75,6 +75,7 @@ public void checkServerTrusted(X509Certificate[] arg0, String arg1) throws Certi .build(); private final ObjectMapper mapper = new ObjectMapper(); private int statusCode; + private String body; private JsonNode json; private JsonNode resources; private JsonNode responseObject; @@ -163,7 +164,7 @@ public void request(String endpoint, String method) { @When("I request {string} with method {string} with body") public void requestWithTable(String endpoint, String method, DataTable table) { - this.requestFull(endpoint, method, createBody(table), MediaType.TEXT_PLAIN); + this.requestFull(endpoint, method, createBody(table), MediaType.APPLICATION_JSON); } @When("I request {string} with method {string} with json") @@ -235,6 +236,8 @@ public void requestFull(String endpoint, String method, Entity body, String c } catch (IOException e) { LOGGER.error("Can't read body", e); } + } else { + this.body = response.readEntity(String.class); } } @@ -364,6 +367,11 @@ public void expectResponseIsEqualsTo(String value) { assertEquals(json.toString(), value); } + @Then("I expect body is {string}") + public void expectBodyIsEqualsTo(String value) { + assertEquals(this.body, value); + } + @Then("I expect response resources value is {string}") public void expectResponseIs(String value) { assertEquals(resources.toString(), value); @@ -423,6 +431,11 @@ public void cleanLibrary(String url) throws URISyntaxException, IOException, Int this.clean("libraries", String.format("url=%s", url)); } + @And("I clean the AI conversation {string}") + public void cleanAiConversation(String key) throws URISyntaxException, IOException, InterruptedException { + this.clean("ai/conversations", String.format("key=%s", key)); + } + public void clean(String entity, String query) throws URISyntaxException, IOException, InterruptedException { this.request(String.format("/%s?%s", entity, query)); if (statusCode == 200 && json.get("totalElements").asInt() > 0) { diff --git a/src/test/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapperTest.java b/src/test/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapperTest.java new file mode 100644 index 00000000..9953f9f2 --- /dev/null +++ b/src/test/java/com/ditrit/letomodelizerapi/model/mapper/ai/AIMessageToDTOMapperTest.java @@ -0,0 +1,30 @@ +package com.ditrit.letomodelizerapi.model.mapper.ai; + +import com.ditrit.letomodelizerapi.model.ai.AIMessageDTO; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Tag("unit") +@DisplayName("Test class: AIMessageToDTOMapper") +class AIMessageToDTOMapperTest { + + @Test + @DisplayName("Test apply: should map to dto.") + void testApply() { + final AIMessageToDTOMapper mapper = new AIMessageToDTOMapper(); + + AIMessage message = new AIMessage(); + message.setMessage("test".getBytes()); + + AIMessageDTO dto = mapper.apply(message); + assertNotNull(dto); + assertEquals("test", new String(Base64.getDecoder().decode(dto.getMessage()))); + } +} diff --git a/src/test/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilterTest.java b/src/test/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilterTest.java index c5b1ab00..a9b97e64 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilterTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/persistence/specification/filter/DatePredicateFilterTest.java @@ -49,25 +49,25 @@ void extractTest() { assertNotNull(exception); assertEquals(ErrorType.WRONG_FILTER_VALUE.getMessage(), exception.getError().getMessage()); - DatePredicateFilter filter = new DatePredicateFilter(null, "2019-01-01 00:00:00"); + DatePredicateFilter filter = new DatePredicateFilter(null, "2019-01-01 00:00:00.000"); assertTrue(filter.extract()); assertEquals(PredicateOperator.EQUALS, filter.getOperator(0)); - assertEquals("2019-01-01 00:00:00", filter.getValue(0)); + assertEquals("2019-01-01 00:00:00.000", filter.getValue(0)); - filter = new DatePredicateFilter(null, "gt2019-01-01 00:00:00"); + filter = new DatePredicateFilter(null, "gt2019-01-01 00:00:00.000"); assertTrue(filter.extract()); assertEquals(PredicateOperator.SUPERIOR, filter.getOperator(0)); - assertEquals("2019-01-01 00:00:00", filter.getValue(0)); + assertEquals("2019-01-01 00:00:00.000", filter.getValue(0)); - filter = new DatePredicateFilter(null, "lt2019-01-01 00:00:00"); + filter = new DatePredicateFilter(null, "lt2019-01-01 00:00:00.000"); assertTrue(filter.extract()); assertEquals(PredicateOperator.INFERIOR, filter.getOperator(0)); - assertEquals("2019-01-01 00:00:00", filter.getValue(0)); + assertEquals("2019-01-01 00:00:00.000", filter.getValue(0)); - filter = new DatePredicateFilter(null, "2018-01-01 00:00:00bt2019-01-01 00:00:00"); + filter = new DatePredicateFilter(null, "2018-01-01 00:00:00.000bt2019-01-01 00:00:00.000"); assertTrue(filter.extract()); assertEquals(PredicateOperator.BETWEEN, filter.getOperator(0)); - assertEquals("2019-01-01 00:00:00", filter.getValue(0)); + assertEquals("2019-01-01 00:00:00.000", filter.getValue(0)); filter = new DatePredicateFilter(null, "null"); assertTrue(filter.extract()); @@ -82,7 +82,7 @@ void extractTest() { exception = null; try { - new DatePredicateFilter(null, "bt2019-01-01 00:00:00").extract(); + new DatePredicateFilter(null, "bt2019-01-01 00:00:00.000").extract(); } catch (ApiException e) { exception = e; } @@ -96,7 +96,7 @@ void extractTest() { void getSpecificOperatorTest() { ApiException exception = null; try { - new DatePredicateFilter(null, "2019-01-01 00:00:00aa2019-01-01 00:00:00").extract(); + new DatePredicateFilter(null, "2019-01-01 00:00:00.000aa2019-01-01 00:00:00.000").extract(); } catch (ApiException e) { exception = e; } @@ -105,7 +105,7 @@ void getSpecificOperatorTest() { assertEquals(ErrorType.WRONG_FILTER_OPERATOR.getMessage(), exception.getError().getMessage()); exception = null; try { - new DatePredicateFilter(null, "not_2019-01-01 00:00:00").extract(); + new DatePredicateFilter(null, "not_2019-01-01 00:00:00.000").extract(); } catch (ApiException e) { exception = e; } @@ -127,7 +127,7 @@ void getDateTest() { Date date = null; exception = null; try { - date = filter.getDate("2019-12-01 00:00:00"); + date = filter.getDate("2019-12-01 00:00:00.000"); } catch (final ApiException e) { exception = e; } @@ -144,27 +144,27 @@ void getPredicateTest() { final CriteriaQuery query = builder.createQuery(Entity.class); final Root root = query.from(Entity.class); - DatePredicateFilter filter = new DatePredicateFilter("name", "2019-01-01 00:00:00|not_2018-01-01 00:00:00"); + DatePredicateFilter filter = new DatePredicateFilter("name", "2019-01-01 00:00:00.000|not_2018-01-01 00:00:00.000"); assertTrue(filter.extract()); Predicate predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); - filter = new DatePredicateFilter("date", "lt2019-01-01 00:00:00"); + filter = new DatePredicateFilter("date", "lt2019-01-01 00:00:00.000"); assertTrue(filter.extract()); predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); - filter = new DatePredicateFilter("date", "gt2019-01-01 00:00:00"); + filter = new DatePredicateFilter("date", "gt2019-01-01 00:00:00.000"); assertTrue(filter.extract()); predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); - filter = new DatePredicateFilter("date", "2019-01-01 00:00:00bt2019-01-02 00:00:00"); + filter = new DatePredicateFilter("date", "2019-01-01 00:00:00.000bt2019-01-02 00:00:00.000"); assertTrue(filter.extract()); predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); assertEquals(1, filter.getValues().length); - assertEquals("2019-01-02 00:00:00", filter.getValue(0)); + assertEquals("2019-01-02 00:00:00.000", filter.getValue(0)); filter = new DatePredicateFilter("date", "NULL"); assertTrue(filter.extract()); @@ -176,17 +176,17 @@ void getPredicateTest() { predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); - filter = new DatePredicateFilter("date", "NOT_2019-01-01 00:00:00"); + filter = new DatePredicateFilter("date", "NOT_2019-01-01 00:00:00.000"); assertTrue(filter.extract()); predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); - filter = new DatePredicateFilter("date", "not_2019-01-01 00:00:00bt2019-01-02 00:00:00"); + filter = new DatePredicateFilter("date", "not_2019-01-01 00:00:00.000bt2019-01-02 00:00:00.000"); assertTrue(filter.extract()); predicate = filter.getPredicate(builder, root, null); assertNotNull(predicate); assertEquals(1, filter.getValues().length); - assertEquals("2019-01-02 00:00:00", filter.getValue(0)); + assertEquals("2019-01-02 00:00:00.000", filter.getValue(0)); } class Entity { diff --git a/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java b/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java index 4c24df4f..81ea02c2 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java @@ -1,31 +1,51 @@ package com.ditrit.letomodelizerapi.service; -import com.ditrit.letomodelizerapi.model.ai.AIRequestRecord; +import com.ditrit.letomodelizerapi.model.ai.AIConversationRecord; +import com.ditrit.letomodelizerapi.model.ai.AICreateFileRecord; import com.ditrit.letomodelizerapi.model.error.ApiException; import com.ditrit.letomodelizerapi.model.error.ErrorType; +import com.ditrit.letomodelizerapi.persistence.model.AIConversation; +import com.ditrit.letomodelizerapi.persistence.model.AIMessage; +import com.ditrit.letomodelizerapi.persistence.model.User; +import com.ditrit.letomodelizerapi.persistence.repository.AIConversationRepository; +import com.ditrit.letomodelizerapi.persistence.repository.AIMessageRepository; +import com.fasterxml.jackson.core.JsonProcessingException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import java.io.IOException; import java.net.http.HttpClient; import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; @Tag("unit") @ExtendWith(MockitoExtension.class) @DisplayName("AIServiceImpl Unit Tests") class AIServiceImplTest { + @Mock + AIConversationRepository aiConversationRepository; + + @Mock + AIMessageRepository aiMessageRepository; + AIServiceImpl newInstance() { - return new AIServiceImpl("http://localhost:8585/"); + return new AIServiceImpl(aiConversationRepository, aiMessageRepository, "http://localhost:8585/"); } private static void mockHttpCall(MockedStatic clientStatic, int expectedStatus, String expectedBody) throws IOException, InterruptedException { @@ -45,25 +65,24 @@ private static void mockHttpCall(MockedStatic clientStatic, int expe } @Test - @DisplayName("Test sendRequest: should return valid response when a valid request is done") - void testSendRequest() throws IOException, InterruptedException { - + @DisplayName("Test createFile: should return valid response when a valid request is done") + void testCreateFile() throws IOException, InterruptedException { String expectedBody = "[{\"name\": \"deployment.yaml\", \"content\": \"apiVersion: apps/v1\\nkind: Deployment\"}]"; MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); mockHttpCall(clientStatic, HttpStatus.OK.value(), expectedBody); AIServiceImpl service = newInstance(); - AIRequestRecord aiRequestRecord = new AIRequestRecord("@ditrit/kubernator-plugin", "diagram", "I want a sample of kubernetes code"); - assertEquals(expectedBody, service.sendRequest(aiRequestRecord)); + AICreateFileRecord aiCreateFileRecord = new AICreateFileRecord("@ditrit/kubernator-plugin", "diagram", "I want a sample of kubernetes code"); + assertEquals(expectedBody, service.createFile(aiCreateFileRecord)); Mockito.reset(); clientStatic.close(); } @Test - @DisplayName("Test sendRequest: should return a 530 due to an error in the body") - void testSendRequestWrongBody() throws IOException, InterruptedException { + @DisplayName("Test createFile: should return a 530 due to an error in the body") + void testCreateFileWrongBody() throws IOException, InterruptedException { MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); mockHttpCall(clientStatic,530, null); @@ -73,8 +92,8 @@ void testSendRequestWrongBody() throws IOException, InterruptedException { ApiException exception = null; try { - AIRequestRecord aiRequestRecord = new AIRequestRecord("@ditrit/kubernator-plugin", "diagram", "coucou"); - service.sendRequest(aiRequestRecord); + AICreateFileRecord aiCreateFileRecord = new AICreateFileRecord("@ditrit/kubernator-plugin", "diagram", "coucou"); + service.createFile(aiCreateFileRecord); } catch (ApiException e) { exception = e; } @@ -88,9 +107,8 @@ void testSendRequestWrongBody() throws IOException, InterruptedException { } @Test - @DisplayName("Test sendRequest: should return a 206 (Wrong value) due to an error in the url") - void testSendRequestWrongUrl() throws IOException, InterruptedException { - + @DisplayName("Test createFile: should return a 206 (Wrong value) due to an error in the url") + void testCreateFileWrongUrl() throws IOException, InterruptedException { MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); mockHttpCall(clientStatic,400, null); @@ -99,8 +117,8 @@ void testSendRequestWrongUrl() throws IOException, InterruptedException { ApiException exception = null; try { - AIRequestRecord aiRequestRecord = new AIRequestRecord("@ditrit/kubernator-plugin", "diagram", "coucou"); - service.sendRequest(aiRequestRecord); + AICreateFileRecord aiCreateFileRecord = new AICreateFileRecord("@ditrit/kubernator-plugin", "diagram", "coucou"); + service.createFile(aiCreateFileRecord); } catch (ApiException e) { exception = e; } @@ -113,5 +131,333 @@ void testSendRequestWrongUrl() throws IOException, InterruptedException { clientStatic.close(); } -} + @Test + @DisplayName("Test createConversation: should throw ENTITY_ALREADY_EXISTS") + void testCreateConversation() throws JsonProcessingException { + Mockito.when(aiConversationRepository.existsByUserIdAndKey(Mockito.any(), Mockito.any())).thenReturn(true); + AIServiceImpl service = newInstance(); + ApiException exception = null; + User user = new User(); + user.setId(UUID.randomUUID()); + + AIConversationRecord conversationRecord = new AIConversationRecord("test", "test", "test", "checksum", List.of()); + + try { + service.createConversation(user, conversationRecord); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_ALREADY_EXISTS.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_ALREADY_EXISTS.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test createConversation: should conversation with new context") + void testCreateConversationWithNewContext() throws IOException, InterruptedException { + MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); + mockHttpCall(clientStatic,200, "{\"context\": \"[\\\"newContext\\\"]\"}"); + + AIConversation conversation = new AIConversation(); + conversation.setContext("newContext"); + + Mockito.when(aiConversationRepository.existsByUserIdAndKey(Mockito.any(), Mockito.any())).thenReturn(false); + Mockito.when(aiConversationRepository.save(Mockito.any())).thenReturn(conversation); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + AIConversationRecord conversationRecord = new AIConversationRecord("test", "test", "test", "checksum", List.of()); + + AIConversation expectedConversation = service.createConversation(user, conversationRecord); + + assertEquals("newContext", expectedConversation.getContext()); + + Mockito.reset(); + clientStatic.close(); + } + + @Test + @DisplayName("Test getConversationById: should return conversation") + void testGetConversationById() { + AIConversation expectedConversation = new AIConversation(); + + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + + AIConversation conversation = service.getConversationById(user, UUID.randomUUID()); + + assertEquals(conversation, expectedConversation); + } + + @Test + @DisplayName("Test getConversationById: should throw exception on unknown id") + void testGetConversationByIdThrowException() { + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + + ApiException exception = null; + + try { + service.getConversationById(user, UUID.randomUUID()); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test updateConversationById: should return updated conversation") + void testUpdateConversationById() throws IOException, InterruptedException { + MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); + mockHttpCall(clientStatic,200, "{\"context\": \"[\\\"newContext\\\"]\"}"); + AIConversation expectedConversation = new AIConversation(); + expectedConversation.setKey("test/test/test"); + expectedConversation.setSize(0L); + + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + Mockito.when(aiConversationRepository.save(Mockito.any())).thenReturn(expectedConversation); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + AIConversationRecord conversationRecord = new AIConversationRecord("test", "test", "test", "checksum", List.of()); + + AIConversation conversation = service.updateConversationById(user, UUID.randomUUID(), conversationRecord); + + assertEquals(conversation, expectedConversation); + + Mockito.reset(); + clientStatic.close(); + } + + @Test + @DisplayName("Test updateConversationById: should throw exception on unknown id") + void testUpdateConversationByIdThrowException() throws IOException { + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + AIConversationRecord conversationRecord = new AIConversationRecord("test", "test", "test", "checksum", List.of()); + + ApiException exception = null; + + try { + service.updateConversationById(user, UUID.randomUUID(), conversationRecord); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test deleteConversationById: should delete conversation") + void testDeleteConversationById() { + AIConversation expectedConversation = new AIConversation(); + + Mockito.when(aiConversationRepository.findById(Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + Mockito.doNothing().when(aiConversationRepository).delete(Mockito.any()); + AIServiceImpl service = newInstance(); + ApiException exception = null; + + try { + service.deleteConversationById(UUID.randomUUID()); + } catch (ApiException e) { + exception = e; + } + + assertNull(exception); + } + + @Test + @DisplayName("Test deleteConversationById: should throw exception on unknown id") + void testDeleteConversationByIdThrowException() { + Mockito.when(aiConversationRepository.findById(Mockito.any())) + .thenReturn(Optional.empty()); + AIServiceImpl service = newInstance(); + ApiException exception = null; + + try { + service.deleteConversationById(UUID.randomUUID()); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test deleteConversationById: should delete conversation") + void testDeleteConversationByIdAndUser() { + AIConversation expectedConversation = new AIConversation(); + + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + Mockito.doNothing().when(aiConversationRepository).delete(Mockito.any()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + ApiException exception = null; + + try { + service.deleteConversationById(user, UUID.randomUUID()); + } catch (ApiException e) { + exception = e; + } + + assertNull(exception); + } + + @Test + @DisplayName("Test deleteConversationById: should throw exception on unknown id") + void testDeleteConversationByIdAndUserThrowException() { + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + ApiException exception = null; + + try { + service.deleteConversationById(user, UUID.randomUUID()); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test sendMessage: should AI message") + void testSendMessage() throws IOException, InterruptedException { + MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class); + mockHttpCall(clientStatic,200, "{\"context\": \"[\\\"newContext\\\"]\", \"message\": \"test\"}"); + AIConversation expectedConversation = new AIConversation(); + expectedConversation.setId(UUID.randomUUID()); + expectedConversation.setSize(0L); + + AIMessage expectedMessage = new AIMessage(); + + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + Mockito.when(aiConversationRepository.save(Mockito.any())).thenReturn(expectedConversation); + Mockito.when(aiMessageRepository.save(Mockito.any())).thenReturn(expectedMessage); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + + AIMessage message = service.sendMessage(user, UUID.randomUUID(), "ok"); + + assertEquals(message, expectedMessage); + + Mockito.reset(); + clientStatic.close(); + } + + @Test + @DisplayName("Test sendMessage: should throw exception on unknown id") + void testSendMessageThrowException() throws IOException { + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + ApiException exception = null; + + try { + service.sendMessage(user, UUID.randomUUID(), "test"); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test findAll: should return conversations") + void testFindAll() { + AIConversation expectedConversation = new AIConversation(); + expectedConversation.setId(UUID.randomUUID()); + + Mockito.when(aiConversationRepository.findAll(Mockito.any(Specification.class), Mockito.any())) + .thenReturn(Page.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + + assertEquals(Page.empty(), service.findAll(user, Map.of(), Pageable.ofSize(10))); + } + + @Test + @DisplayName("Test findAllMessages: should return messages") + void testFindAllMessages() { + AIConversation expectedConversation = new AIConversation(); + expectedConversation.setId(UUID.randomUUID()); + + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(expectedConversation)); + Mockito.when(aiMessageRepository.findAll(Mockito.any(Specification.class), Mockito.any())) + .thenReturn(Page.empty()); + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + + assertEquals(Page.empty(), service.findAllMessages(user, UUID.randomUUID(), Map.of(), Pageable.ofSize(10))); + } + + @Test + @DisplayName("Test findAllMessages: should return messages") + void testFindAllMessagesThrowException() { + Mockito.when(aiConversationRepository.findByIdAndUserId(Mockito.any(), Mockito.any())) + .thenReturn(Optional.empty()); + + AIServiceImpl service = newInstance(); + User user = new User(); + user.setId(UUID.randomUUID()); + ApiException exception = null; + + try { + service.findAllMessages(user, UUID.randomUUID(), Map.of(), Pageable.ofSize(10)); + } catch (ApiException e) { + exception = e; + } + + assertNotNull(exception); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus()); + assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("Test findAllConversations: should return conversations") + void testFindAllConversations() { + AIConversation expectedConversation = new AIConversation(); + expectedConversation.setId(UUID.randomUUID()); + + Mockito.when(aiConversationRepository.findAll(Mockito.any(Specification.class), Mockito.any())) + .thenReturn(Page.empty()); + AIServiceImpl service = newInstance(); + + assertEquals(Page.empty(), service.findAllConversations(Map.of(), Pageable.ofSize(10))); + } +} diff --git a/src/test/java/com/ditrit/letomodelizerapi/service/UserServiceImplTest.java b/src/test/java/com/ditrit/letomodelizerapi/service/UserServiceImplTest.java index d15b8c08..3ecd9d6e 100644 --- a/src/test/java/com/ditrit/letomodelizerapi/service/UserServiceImplTest.java +++ b/src/test/java/com/ditrit/letomodelizerapi/service/UserServiceImplTest.java @@ -231,6 +231,7 @@ void testGetPicture() throws IOException, InterruptedException { clientStatic.when(HttpClient::newBuilder).thenReturn(clientBuilder); assertEquals(expectedResponse, service.getPicture(user)); + Mockito.reset(); requestStatic.close(); clientStatic.close(); } diff --git a/src/test/resources/ai/index.php b/src/test/resources/ai/index.php index 32266ea5..2c8c2a82 100644 --- a/src/test/resources/ai/index.php +++ b/src/test/resources/ai/index.php @@ -11,9 +11,19 @@ error_log($type); error_log(file_get_contents('php://input')); -if ($type && isset($requestBody['pluginName'])) { - $fileName = "{$type}_{$requestBody['pluginName']}.json"; - error_log($fileName); +if ($type == "chat") { + $contextValue = isset($requestBody["context"]) ? (int) $requestBody["context"] : 0; + $data = [ + "context" =>1 + $contextValue, + "message" => "OK" + ]; + header('Content-Type: application/json'); + $json = json_encode($data); + echo $json; + exit; +} else if ($type && isset($requestBody['pluginName'])) { + $fileName = $type . "_" . str_replace("@ditrit/", "", $requestBody['pluginName']) . ".json"; + error_log($fileName); if (file_exists($fileName)) { sleep(rand(5, 10)); diff --git a/src/test/resources/features/AI.feature b/src/test/resources/features/AI.feature index 00602c70..257b8517 100644 --- a/src/test/resources/features/AI.feature +++ b/src/test/resources/features/AI.feature @@ -3,10 +3,61 @@ Feature: ai feature Scenario: Should return 201 on a valid ai diagram creation Given I initialize the admin user - When I request "/ai" with method "POST" with json + When I request "/ai/generate" with method "POST" with json | key | value | | plugin | terrator-plugin | | type | diagram | | description | Test diagram creation | Then I expect "201" as status code And I expect response is '[{"name":"main.tf","content":"provider \\"aws\\" {}"}]' + + Scenario: Should return 201 on a valid AI conversation creation + Given I initialize the admin user + And I clean the AI conversation "TEST/TEST/@ditrit/terrator-plugin" + + When I request "/ai/conversations" with method "POST" with json + | key | value | type | + | project | TEST | string | + | diagram | TEST | string | + | plugin | @ditrit/terrator-plugin | string | + | checksum | ABCDEF | string | + | files | [] | array | + Then I expect "201" as status code + And I expect response fields length is "5" + And I expect response field "key" is "TEST/TEST/@ditrit/terrator-plugin" + And I expect response field "id" is "NOT_NULL" + And I expect response field "checksum" is "ABCDEF" + And I expect response field "size" is "0" + And I expect response field "updateDate" is "NOT_NULL" + And I set response field "id" to context "conversation_id" + + When I request "/ai/conversations/[conversation_id]" with method "GET" + Then I expect "200" as status code + And I expect response fields length is "5" + And I expect response field "key" is "TEST/TEST/@ditrit/terrator-plugin" + And I expect response field "id" is "NOT_NULL" + And I expect response field "size" is "0" + And I expect response field "checksum" is "ABCDEF" + And I expect response field "updateDate" is "NOT_NULL" + + When I request "/users/admin/ai/conversations" with method "GET" + Then I expect "200" as status code + And I extract resources from response + And I expect one resource contains "id" equals to "[conversation_id]" + + When I request "/ai/conversations/[conversation_id]/messages" with method "POST" with body + | body | type | + | test | String | + Then I expect "201" as status code + And I expect response fields length is "5" + And I expect response field "id" is "NOT_NULL" + And I expect response field "aiConversation" is "[conversation_id]" + And I expect response field "isUser" is "false" + And I expect response field "message" is "H4sIAAAAAAAA//P3BgAt2TbXAgAAAA==" + And I expect response field "updateDate" is "NOT_NULL" + + When I request "/ai/conversations/[conversation_id]" with method "DELETE" + Then I expect "204" as status code + + When I request "/ai/conversations/[conversation_id]" with method "GET" + Then I expect "404" as status code