From 82074add453b1115a9f26ff8c8763ace28fe7bd0 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Sat, 6 Jun 2026 20:34:39 -0300 Subject: [PATCH] feat(project): implement CRUD operations for projects --- .../controller/ProjectController.java | 66 +++++ .../controller/WorkspaceController.java | 12 +- .../workspace/model/dto/ProjectRequest.java | 5 + .../workspace/model/dto/ProjectResponse.java | 23 ++ .../model/dto/WorkspaceResponse.java | 10 +- .../workspace/model/entity/Project.java | 39 +++ .../workspace/model/entity/Workspace.java | 4 + .../model/repository/ProjectRepository.java | 12 + .../specification/ProjectSpecification.java | 18 ++ .../specification/WorkspaceSpecification.java | 6 +- .../workspace/service/ProjectService.java | 77 +++++ .../workspace/service/WorkspaceService.java | 15 +- .../migration/V003__create_projects_table.sql | 9 + .../controller/ProjectControllerTest.java | 196 +++++++++++++ .../controller/ProjectIntegrationTest.java | 274 ++++++++++++++++++ .../controller/WorkspaceControllerTest.java | 51 +++- .../controller/WorkspaceIntegrationTest.java | 54 +++- .../workspace/service/ProjectServiceTest.java | 214 ++++++++++++++ .../service/WorkspaceServiceTest.java | 25 ++ 19 files changed, 1075 insertions(+), 35 deletions(-) create mode 100644 src/main/java/ai/decompile/workspace/controller/ProjectController.java create mode 100644 src/main/java/ai/decompile/workspace/model/dto/ProjectRequest.java create mode 100644 src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java create mode 100644 src/main/java/ai/decompile/workspace/model/entity/Project.java create mode 100644 src/main/java/ai/decompile/workspace/model/repository/ProjectRepository.java create mode 100644 src/main/java/ai/decompile/workspace/model/specification/ProjectSpecification.java create mode 100644 src/main/java/ai/decompile/workspace/service/ProjectService.java create mode 100644 src/main/resources/db/migration/V003__create_projects_table.sql create mode 100644 src/test/java/ai/decompile/project/controller/ProjectControllerTest.java create mode 100644 src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java create mode 100644 src/test/java/ai/decompile/workspace/service/ProjectServiceTest.java diff --git a/src/main/java/ai/decompile/workspace/controller/ProjectController.java b/src/main/java/ai/decompile/workspace/controller/ProjectController.java new file mode 100644 index 0000000..3d47fa9 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/controller/ProjectController.java @@ -0,0 +1,66 @@ +package ai.decompile.workspace.controller; + +import ai.decompile.workspace.model.dto.ProjectRequest; +import ai.decompile.workspace.model.dto.ProjectResponse; +import ai.decompile.workspace.service.ProjectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/workspaces/{workspaceId}/projects") +@RequiredArgsConstructor +public class ProjectController { + private final ProjectService projectService; + + @Operation( + summary = "List projects of a workspace", + description = "Retrieve all projects belonging to a workspace, optionally filtered by name.", + tags = {"Project"}, + operationId = "getProjects") + @GetMapping + public List getProjects( + @PathVariable UUID workspaceId, + @Parameter(description = "Filter projects whose name contains this value (case-insensitive)") + @RequestParam(required = false) + String name) { + return projectService.getProjects(workspaceId, name); + } + + @Operation( + summary = "Create a project", + description = "Create a project in a workspace.", + tags = {"Project"}, + operationId = "createProject") + @PostMapping + public ProjectResponse createProject( + @PathVariable UUID workspaceId, @RequestBody ProjectRequest request) { + return projectService.createProject(workspaceId, request); + } + + @Operation( + summary = "Update a project", + description = "Update a project's name and description.", + tags = {"Project"}, + operationId = "updateProject") + @PutMapping("/{projectId}") + public ProjectResponse updateProject( + @PathVariable UUID workspaceId, + @PathVariable UUID projectId, + @RequestBody ProjectRequest request) { + return projectService.updateProject(projectId, request); + } + + @Operation( + summary = "Delete a project", + description = "Delete a project.", + tags = {"Project"}, + operationId = "deleteProject") + @DeleteMapping("/{projectId}") + public void deleteProject(@PathVariable UUID workspaceId, @PathVariable UUID projectId) { + projectService.deleteProject(projectId); + } +} diff --git a/src/main/java/ai/decompile/workspace/controller/WorkspaceController.java b/src/main/java/ai/decompile/workspace/controller/WorkspaceController.java index 567b99f..52cca36 100644 --- a/src/main/java/ai/decompile/workspace/controller/WorkspaceController.java +++ b/src/main/java/ai/decompile/workspace/controller/WorkspaceController.java @@ -11,7 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/workspace") +@RequestMapping("/workspaces") @RequiredArgsConstructor public class WorkspaceController { private final WorkspaceService workspaceService; @@ -30,6 +30,16 @@ public class WorkspaceController { return workspaceService.getWorkspaces(name); } + @Operation( + summary = "Get a workspace by ID", + description = "Retrieve a single workspace by its unique identifier.", + tags = {"Workspace"}, + operationId = "getWorkspace") + @GetMapping("/{id}") + public WorkspaceResponse getWorkspace(@PathVariable UUID id) { + return workspaceService.getWorkspaceById(id); + } + @Operation( summary = "Create a workspace", description = "Create a workspace", diff --git a/src/main/java/ai/decompile/workspace/model/dto/ProjectRequest.java b/src/main/java/ai/decompile/workspace/model/dto/ProjectRequest.java new file mode 100644 index 0000000..9ff45a5 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/dto/ProjectRequest.java @@ -0,0 +1,5 @@ +package ai.decompile.workspace.model.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ProjectRequest(@NotBlank String name, String description) {} diff --git a/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java b/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java new file mode 100644 index 0000000..4e0ac01 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java @@ -0,0 +1,23 @@ +package ai.decompile.workspace.model.dto; + +import ai.decompile.workspace.model.entity.Project; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.UUID; + +public record ProjectResponse( + @NotNull UUID id, + @NotNull UUID workspaceId, + @NotBlank String name, + String description, + @NotNull Instant updatedAt) { + public static ProjectResponse from(Project project) { + return new ProjectResponse( + project.getId(), + project.getWorkspace().getId(), + project.getName(), + project.getDescription(), + project.getUpdatedAt()); + } +} diff --git a/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java b/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java index 2ad65a5..e9959bd 100644 --- a/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java +++ b/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java @@ -7,12 +7,18 @@ import java.time.Instant; import java.util.UUID; public record WorkspaceResponse( - @NotNull UUID id, @NotBlank String name, String description, @NotNull Instant updatedAt) { + @NotNull UUID id, + @NotBlank String name, + String description, + @NotNull Instant updatedAt, + int projectCount) { public static WorkspaceResponse from(Workspace workspace) { + var projects = workspace.getProjects(); return new WorkspaceResponse( workspace.getId(), workspace.getName(), workspace.getDescription(), - workspace.getUpdatedAt()); + workspace.getUpdatedAt(), + projects != null ? projects.size() : 0); } } diff --git a/src/main/java/ai/decompile/workspace/model/entity/Project.java b/src/main/java/ai/decompile/workspace/model/entity/Project.java new file mode 100644 index 0000000..180029e --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/entity/Project.java @@ -0,0 +1,39 @@ +package ai.decompile.workspace.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "projects") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Project { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, length = 150) + private String name; + + @Column(length = 500) + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workspace_id", nullable = false) + private Workspace workspace; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/ai/decompile/workspace/model/entity/Workspace.java b/src/main/java/ai/decompile/workspace/model/entity/Workspace.java index e381ced..3786f60 100644 --- a/src/main/java/ai/decompile/workspace/model/entity/Workspace.java +++ b/src/main/java/ai/decompile/workspace/model/entity/Workspace.java @@ -2,6 +2,7 @@ package ai.decompile.workspace.model.entity; import jakarta.persistence.*; import java.time.Instant; +import java.util.List; import java.util.UUID; import lombok.*; import org.hibernate.annotations.CreationTimestamp; @@ -32,4 +33,7 @@ public class Workspace { @UpdateTimestamp @Column(name = "updated_at", nullable = false) private Instant updatedAt; + + @OneToMany(mappedBy = "workspace", cascade = CascadeType.ALL, orphanRemoval = true) + private List projects; } diff --git a/src/main/java/ai/decompile/workspace/model/repository/ProjectRepository.java b/src/main/java/ai/decompile/workspace/model/repository/ProjectRepository.java new file mode 100644 index 0000000..046cafd --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/repository/ProjectRepository.java @@ -0,0 +1,12 @@ +package ai.decompile.workspace.model.repository; + +import ai.decompile.workspace.model.entity.Project; +import jakarta.validation.constraints.NotBlank; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface ProjectRepository + extends JpaRepository, JpaSpecificationExecutor { + boolean existsByNameIgnoreCaseAndWorkspace_Id(@NotBlank String name, UUID workspaceId); +} diff --git a/src/main/java/ai/decompile/workspace/model/specification/ProjectSpecification.java b/src/main/java/ai/decompile/workspace/model/specification/ProjectSpecification.java new file mode 100644 index 0000000..aa4536a --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/specification/ProjectSpecification.java @@ -0,0 +1,18 @@ +package ai.decompile.workspace.model.specification; + +import ai.decompile.workspace.model.entity.Project; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.Specification; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectSpecification { + public static Specification nameContains(String name) { + return (root, _, cb) -> cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"); + } + + public static Specification hasWorkspaceId(UUID workspaceId) { + return (root, _, cb) -> cb.equal(root.get("workspace").get("id"), workspaceId); + } +} diff --git a/src/main/java/ai/decompile/workspace/model/specification/WorkspaceSpecification.java b/src/main/java/ai/decompile/workspace/model/specification/WorkspaceSpecification.java index 3c9712e..87b1865 100644 --- a/src/main/java/ai/decompile/workspace/model/specification/WorkspaceSpecification.java +++ b/src/main/java/ai/decompile/workspace/model/specification/WorkspaceSpecification.java @@ -1,12 +1,12 @@ package ai.decompile.workspace.model.specification; import ai.decompile.workspace.model.entity.Workspace; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import org.springframework.data.jpa.domain.Specification; +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class WorkspaceSpecification { - - private WorkspaceSpecification() {} - public static Specification nameContains(String name) { return (root, _, cb) -> cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"); } diff --git a/src/main/java/ai/decompile/workspace/service/ProjectService.java b/src/main/java/ai/decompile/workspace/service/ProjectService.java new file mode 100644 index 0000000..e83ba67 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/service/ProjectService.java @@ -0,0 +1,77 @@ +package ai.decompile.workspace.service; + +import ai.decompile.exception.ConflictException; +import ai.decompile.exception.NotFoundException; +import ai.decompile.workspace.model.dto.ProjectRequest; +import ai.decompile.workspace.model.dto.ProjectResponse; +import ai.decompile.workspace.model.entity.Project; +import ai.decompile.workspace.model.repository.ProjectRepository; +import ai.decompile.workspace.model.specification.ProjectSpecification; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProjectService { + private final WorkspaceService workspaceService; + private final ProjectRepository projectRepository; + + public List getProjects(UUID workspaceId, String name) { + Specification spec = ProjectSpecification.hasWorkspaceId(workspaceId); + if (StringUtils.isNotBlank(name)) { + spec = spec.and(ProjectSpecification.nameContains(name)); + } + + return projectRepository.findAll(spec).stream().map(ProjectResponse::from).toList(); + } + + public ProjectResponse createProject(UUID workspaceId, ProjectRequest request) { + var workspace = workspaceService.getWorkspace(workspaceId); + + if (projectRepository.existsByNameIgnoreCaseAndWorkspace_Id(request.name(), workspaceId)) { + throw new ConflictException( + "A project with the name '" + request.name() + "' already exists in this workspace."); + } + + var project = + projectRepository.save( + Project.builder() + .name(request.name()) + .description(request.description()) + .workspace(workspace) + .build()); + + return ProjectResponse.from(project); + } + + public ProjectResponse updateProject(UUID projectId, ProjectRequest request) { + var project = getProject(projectId); + + if (!project.getName().equalsIgnoreCase(request.name()) + && projectRepository.existsByNameIgnoreCaseAndWorkspace_Id( + request.name(), project.getWorkspace().getId())) { + throw new ConflictException( + "A project with the name '" + request.name() + "' already exists in this workspace."); + } + + project.setName(request.name()); + project.setDescription(request.description()); + + return ProjectResponse.from(projectRepository.save(project)); + } + + public void deleteProject(UUID projectId) { + var project = getProject(projectId); + projectRepository.delete(project); + } + + private Project getProject(UUID projectId) { + return projectRepository + .findById(projectId) + .orElseThrow(() -> new NotFoundException("Project with id '" + projectId + "' not found")); + } +} diff --git a/src/main/java/ai/decompile/workspace/service/WorkspaceService.java b/src/main/java/ai/decompile/workspace/service/WorkspaceService.java index 7276478..dc48d02 100644 --- a/src/main/java/ai/decompile/workspace/service/WorkspaceService.java +++ b/src/main/java/ai/decompile/workspace/service/WorkspaceService.java @@ -19,13 +19,18 @@ public class WorkspaceService { private final WorkspaceRepository workspaceRepository; public List getWorkspaces(String name) { + List workspaces; if (StringUtils.isNotBlank(name)) { - return workspaceRepository.findAll(WorkspaceSpecification.nameContains(name)).stream() - .map(WorkspaceResponse::from) - .toList(); + workspaces = workspaceRepository.findAll(WorkspaceSpecification.nameContains(name)); + } else { + workspaces = workspaceRepository.findAll(); } - return workspaceRepository.findAll().stream().map(WorkspaceResponse::from).toList(); + return workspaces.stream().map(WorkspaceResponse::from).toList(); + } + + public WorkspaceResponse getWorkspaceById(UUID id) { + return WorkspaceResponse.from(getWorkspace(id)); } public WorkspaceResponse createWorkspace(WorkspaceRequest request) { @@ -55,7 +60,7 @@ public class WorkspaceService { workspaceRepository.delete(workspace); } - private Workspace getWorkspace(UUID id) { + protected Workspace getWorkspace(UUID id) { return workspaceRepository .findById(id) .orElseThrow(() -> new NotFoundException("Workspace with id '" + id + "' not found")); diff --git a/src/main/resources/db/migration/V003__create_projects_table.sql b/src/main/resources/db/migration/V003__create_projects_table.sql new file mode 100644 index 0000000..edd3dcf --- /dev/null +++ b/src/main/resources/db/migration/V003__create_projects_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE projects +( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + name VARCHAR(150) NOT NULL, + description VARCHAR(500), + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); diff --git a/src/test/java/ai/decompile/project/controller/ProjectControllerTest.java b/src/test/java/ai/decompile/project/controller/ProjectControllerTest.java new file mode 100644 index 0000000..8f1ed82 --- /dev/null +++ b/src/test/java/ai/decompile/project/controller/ProjectControllerTest.java @@ -0,0 +1,196 @@ +package ai.decompile.project.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import ai.decompile.exception.ConflictException; +import ai.decompile.exception.NotFoundException; +import ai.decompile.workspace.controller.ProjectController; +import ai.decompile.workspace.model.dto.ProjectRequest; +import ai.decompile.workspace.model.dto.ProjectResponse; +import ai.decompile.workspace.service.ProjectService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ProjectController.class) +class ProjectControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private ProjectService projectService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void getProjectsShouldReturn200WithList() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + var response = + new ProjectResponse(projectId, workspaceId, "Test Project", "Desc", Instant.now()); + when(projectService.getProjects(eq(workspaceId), isNull())).thenReturn(List.of(response)); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(projectId.toString())) + .andExpect(jsonPath("$[0].name").value("Test Project")) + .andExpect(jsonPath("$[0].workspaceId").value(workspaceId.toString())); + } + + @Test + void getProjectsShouldReturn200WithEmptyList() throws Exception { + var workspaceId = UUID.randomUUID(); + when(projectService.getProjects(eq(workspaceId), isNull())).thenReturn(List.of()); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void getProjectsShouldFilterByName() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + var response = + new ProjectResponse(projectId, workspaceId, "Filtered Project", "Desc", Instant.now()); + when(projectService.getProjects(eq(workspaceId), eq("filter"))).thenReturn(List.of(response)); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId).param("name", "filter")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(projectId.toString())) + .andExpect(jsonPath("$[0].name").value("Filtered Project")); + } + + @Test + void createProjectShouldReturn200() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + var request = new ProjectRequest("New Project", "A description"); + var response = + new ProjectResponse(projectId, workspaceId, "New Project", "A description", Instant.now()); + when(projectService.createProject(eq(workspaceId), any())).thenReturn(response); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(projectId.toString())) + .andExpect(jsonPath("$.name").value("New Project")) + .andExpect(jsonPath("$.workspaceId").value(workspaceId.toString())); + } + + @Test + void createProjectShouldReturn404WhenWorkspaceNotFound() throws Exception { + var workspaceId = UUID.randomUUID(); + var request = new ProjectRequest("New Project", "desc"); + when(projectService.createProject(eq(workspaceId), any())) + .thenThrow(new NotFoundException("Workspace with id '" + workspaceId + "' not found")); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Not Found")); + } + + @Test + void createProjectShouldReturn409OnConflict() throws Exception { + var workspaceId = UUID.randomUUID(); + var request = new ProjectRequest("Duplicate", "desc"); + when(projectService.createProject(eq(workspaceId), any())) + .thenThrow( + new ConflictException( + "A project with the name 'Duplicate' already exists in this workspace.")); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.error").value("Conflict")); + } + + @Test + void updateProjectShouldReturn200() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + var request = new ProjectRequest("Updated", "Updated desc"); + var response = + new ProjectResponse(projectId, workspaceId, "Updated", "Updated desc", Instant.now()); + when(projectService.updateProject(eq(projectId), any())).thenReturn(response); + + mockMvc + .perform( + put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated")); + } + + @Test + void updateProjectShouldReturn404OnNotFound() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + var request = new ProjectRequest("Updated", "desc"); + when(projectService.updateProject(eq(projectId), any())) + .thenThrow(new NotFoundException("Project with id '" + projectId + "' not found")); + + mockMvc + .perform( + put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Not Found")); + } + + @Test + void deleteProjectShouldReturn200() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + doNothing().when(projectService).deleteProject(projectId); + + mockMvc + .perform(delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId)) + .andExpect(status().isOk()); + } + + @Test + void deleteProjectShouldReturn404OnNotFound() throws Exception { + var workspaceId = UUID.randomUUID(); + var projectId = UUID.randomUUID(); + doThrow(new NotFoundException("Project with id '" + projectId + "' not found")) + .when(projectService) + .deleteProject(projectId); + + mockMvc + .perform(delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Not Found")); + } +} diff --git a/src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java b/src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java new file mode 100644 index 0000000..5102683 --- /dev/null +++ b/src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java @@ -0,0 +1,274 @@ +package ai.decompile.project.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import ai.decompile.workspace.model.dto.ProjectRequest; +import ai.decompile.workspace.model.dto.ProjectResponse; +import ai.decompile.workspace.model.dto.WorkspaceRequest; +import ai.decompile.workspace.model.dto.WorkspaceResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ProjectIntegrationTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private EntityManager entityManager; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void getProjectsShouldReturnEmptyList() throws Exception { + var workspaceId = createWorkspace("Empty Workspace"); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void getProjectsShouldFilterByName() throws Exception { + var workspaceId = createWorkspace("Filter WS"); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("Alpha", "desc")))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("Beta", "desc")))) + .andExpect(status().isOk()); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId).param("name", "alpha")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].name").value("Alpha")); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", workspaceId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void fullCrudFlow() throws Exception { + var workspaceId = createWorkspace("CRUD Workspace"); + + var request = new ProjectRequest("CRUD Project", "CRUD Description"); + + var createdJson = + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("CRUD Project")) + .andExpect(jsonPath("$.workspaceId").value(workspaceId.toString())) + .andReturn() + .getResponse() + .getContentAsString(); + + var created = objectMapper.readValue(createdJson, ProjectResponse.class); + + var updateRequest = new ProjectRequest("CRUD Updated", "Updated Description"); + mockMvc + .perform( + put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, created.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("CRUD Updated")); + + mockMvc + .perform( + delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, created.id())) + .andExpect(status().isOk()); + + mockMvc + .perform( + delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, created.id())) + .andExpect(status().isNotFound()); + } + + @Test + void createProjectShouldReturn409OnDuplicateNameInSameWorkspace() throws Exception { + var workspaceId = createWorkspace("Dup WS"); + + var request = new ProjectRequest("Unique Name", "desc"); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.error").value("Conflict")); + } + + @Test + void sameNameInDifferentWorkspacesShouldSucceed() throws Exception { + var ws1 = createWorkspace("Workspace One"); + var ws2 = createWorkspace("Workspace Two"); + + var request = new ProjectRequest("Same Name", "desc"); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", ws1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", ws2) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void createProjectShouldReturn404WhenWorkspaceDoesNotExist() throws Exception { + var nonExistentId = UUID.randomUUID(); + var request = new ProjectRequest("Project", "desc"); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void nonexistentProjectShouldReturn404() throws Exception { + var workspaceId = createWorkspace("WS"); + var nonExistentId = UUID.randomUUID(); + var request = new ProjectRequest("Update", "desc"); + + mockMvc + .perform( + put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, nonExistentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + + mockMvc + .perform( + delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, nonExistentId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void workspaceListShouldReturnProjectCount() throws Exception { + var workspaceId = createWorkspace("Count WS"); + + mockMvc + .perform(get("/workspaces")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].projectCount").value(0)); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("P1", "desc")))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("P2", "desc")))) + .andExpect(status().isOk()); + + entityManager.flush(); + entityManager.clear(); + + mockMvc + .perform(get("/workspaces")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].projectCount").value(2)); + } + + @Test + void listProjectsForSpecificWorkspace() throws Exception { + var ws1 = createWorkspace("WS1"); + var ws2 = createWorkspace("WS2"); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", ws1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("Project A", "desc")))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", ws2) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ProjectRequest("Project B", "desc")))) + .andExpect(status().isOk()); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", ws1)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].name").value("Project A")); + + mockMvc + .perform(get("/workspaces/{workspaceId}/projects", ws2)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].name").value("Project B")); + } + + private UUID createWorkspace(String name) throws Exception { + var request = new WorkspaceRequest(name, "description"); + var json = + mockMvc + .perform( + post("/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return objectMapper.readValue(json, WorkspaceResponse.class).id(); + } +} diff --git a/src/test/java/ai/decompile/workspace/controller/WorkspaceControllerTest.java b/src/test/java/ai/decompile/workspace/controller/WorkspaceControllerTest.java index 099d233..c509ec9 100644 --- a/src/test/java/ai/decompile/workspace/controller/WorkspaceControllerTest.java +++ b/src/test/java/ai/decompile/workspace/controller/WorkspaceControllerTest.java @@ -35,11 +35,11 @@ class WorkspaceControllerTest { @Test void getWorkspacesShouldReturn200WithList() throws Exception { var id = UUID.randomUUID(); - var response = new WorkspaceResponse(id, "Test", "Desc", Instant.now()); + var response = new WorkspaceResponse(id, "Test", "Desc", Instant.now(), 0); when(workspaceService.getWorkspaces(isNull())).thenReturn(List.of(response)); mockMvc - .perform(get("/workspace")) + .perform(get("/workspaces")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].name").value("Test")) @@ -49,26 +49,53 @@ class WorkspaceControllerTest { @Test void getWorkspacesShouldFilterByName() throws Exception { var id = UUID.randomUUID(); - var response = new WorkspaceResponse(id, "Filtered", "Desc", Instant.now()); + var response = new WorkspaceResponse(id, "Filtered", "Desc", Instant.now(), 0); when(workspaceService.getWorkspaces(eq("fil"))).thenReturn(List.of(response)); mockMvc - .perform(get("/workspace").param("name", "fil")) + .perform(get("/workspaces").param("name", "fil")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value(id.toString())) .andExpect(jsonPath("$[0].name").value("Filtered")); } + @Test + void getWorkspaceShouldReturn200() throws Exception { + var id = UUID.randomUUID(); + var response = new WorkspaceResponse(id, "Single", "Desc", Instant.now(), 0); + when(workspaceService.getWorkspaceById(id)).thenReturn(response); + + mockMvc + .perform(get("/workspaces/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id.toString())) + .andExpect(jsonPath("$.name").value("Single")) + .andExpect(jsonPath("$.description").value("Desc")); + } + + @Test + void getWorkspaceShouldReturn404OnNotFound() throws Exception { + var id = UUID.randomUUID(); + when(workspaceService.getWorkspaceById(id)) + .thenThrow(new NotFoundException("Workspace with id '" + id + "' not found")); + + mockMvc + .perform(get("/workspaces/{id}", id)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Not Found")); + } + @Test void createWorkspaceShouldReturn200() throws Exception { var id = UUID.randomUUID(); var request = new WorkspaceRequest("New Workspace", "A description"); - var response = new WorkspaceResponse(id, "New Workspace", "A description", Instant.now()); + var response = new WorkspaceResponse(id, "New Workspace", "A description", Instant.now(), 0); when(workspaceService.createWorkspace(any())).thenReturn(response); mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) @@ -84,7 +111,7 @@ class WorkspaceControllerTest { mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isConflict()) @@ -96,12 +123,12 @@ class WorkspaceControllerTest { void updateWorkspaceShouldReturn200() throws Exception { var id = UUID.randomUUID(); var request = new WorkspaceRequest("Updated", "Updated desc"); - var response = new WorkspaceResponse(id, "Updated", "Updated desc", Instant.now()); + var response = new WorkspaceResponse(id, "Updated", "Updated desc", Instant.now(), 0); when(workspaceService.updateWorkspace(eq(id), any())).thenReturn(response); mockMvc .perform( - put("/workspace/{id}", id) + put("/workspaces/{id}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) @@ -117,7 +144,7 @@ class WorkspaceControllerTest { mockMvc .perform( - put("/workspace/{id}", id) + put("/workspaces/{id}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) @@ -130,7 +157,7 @@ class WorkspaceControllerTest { var id = UUID.randomUUID(); doNothing().when(workspaceService).deleteWorkspace(id); - mockMvc.perform(delete("/workspace/{id}", id)).andExpect(status().isOk()); + mockMvc.perform(delete("/workspaces/{id}", id)).andExpect(status().isOk()); } @Test @@ -141,7 +168,7 @@ class WorkspaceControllerTest { .deleteWorkspace(id); mockMvc - .perform(delete("/workspace/{id}", id)) + .perform(delete("/workspaces/{id}", id)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.error").value("Not Found")); diff --git a/src/test/java/ai/decompile/workspace/controller/WorkspaceIntegrationTest.java b/src/test/java/ai/decompile/workspace/controller/WorkspaceIntegrationTest.java index 00b3058..cdbacce 100644 --- a/src/test/java/ai/decompile/workspace/controller/WorkspaceIntegrationTest.java +++ b/src/test/java/ai/decompile/workspace/controller/WorkspaceIntegrationTest.java @@ -28,12 +28,37 @@ class WorkspaceIntegrationTest { @Test void getWorkspacesShouldReturnEmptyList() throws Exception { mockMvc - .perform(get("/workspace")) + .perform(get("/workspaces")) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$").isEmpty()); } + @Test + void getWorkspaceByIdShouldReturnWorkspace() throws Exception { + var request = new WorkspaceRequest("Get By Id", "desc"); + var createdJson = + mockMvc + .perform( + post("/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + var created = objectMapper.readValue(createdJson, WorkspaceResponse.class); + + mockMvc + .perform(get("/workspaces/{id}", created.id())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(created.id().toString())) + .andExpect(jsonPath("$.name").value("Get By Id")) + .andExpect(jsonPath("$.description").value("desc")) + .andExpect(jsonPath("$.projectCount").value(0)); + } + @Test void getWorkspacesShouldFilterByName() throws Exception { var requestA = new WorkspaceRequest("Alpha Project", "desc"); @@ -41,20 +66,20 @@ class WorkspaceIntegrationTest { mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestA))) .andExpect(status().isOk()); mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestB))) .andExpect(status().isOk()); mockMvc - .perform(get("/workspace").param("name", "alpha")) + .perform(get("/workspaces").param("name", "alpha")) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$.length()").value(1)) @@ -68,7 +93,7 @@ class WorkspaceIntegrationTest { var createdJson = mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) @@ -82,15 +107,15 @@ class WorkspaceIntegrationTest { var updateRequest = new WorkspaceRequest("CRUD Updated", "Updated Description"); mockMvc .perform( - put("/workspace/{id}", created.id()) + put("/workspaces/{id}", created.id()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("CRUD Updated")); - mockMvc.perform(delete("/workspace/{id}", created.id())).andExpect(status().isOk()); + mockMvc.perform(delete("/workspaces/{id}", created.id())).andExpect(status().isOk()); - mockMvc.perform(delete("/workspace/{id}", created.id())).andExpect(status().isNotFound()); + mockMvc.perform(delete("/workspaces/{id}", created.id())).andExpect(status().isNotFound()); } @Test @@ -99,14 +124,14 @@ class WorkspaceIntegrationTest { mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); mockMvc .perform( - post("/workspace") + post("/workspaces") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isConflict()) @@ -119,16 +144,21 @@ class WorkspaceIntegrationTest { var nonExistentId = "00000000-0000-0000-0000-000000000000"; var request = new WorkspaceRequest("Update", "desc"); + mockMvc + .perform(get("/workspaces/{id}", nonExistentId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + mockMvc .perform( - put("/workspace/{id}", nonExistentId) + put("/workspaces/{id}", nonExistentId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)); mockMvc - .perform(delete("/workspace/{id}", nonExistentId)) + .perform(delete("/workspaces/{id}", nonExistentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)); } diff --git a/src/test/java/ai/decompile/workspace/service/ProjectServiceTest.java b/src/test/java/ai/decompile/workspace/service/ProjectServiceTest.java new file mode 100644 index 0000000..97e6e66 --- /dev/null +++ b/src/test/java/ai/decompile/workspace/service/ProjectServiceTest.java @@ -0,0 +1,214 @@ +package ai.decompile.workspace.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import ai.decompile.exception.ConflictException; +import ai.decompile.exception.NotFoundException; +import ai.decompile.workspace.model.dto.ProjectRequest; +import ai.decompile.workspace.model.dto.ProjectResponse; +import ai.decompile.workspace.model.entity.Project; +import ai.decompile.workspace.model.entity.Workspace; +import ai.decompile.workspace.model.repository.ProjectRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class ProjectServiceTest { + + @Mock private ProjectRepository projectRepository; + + @Mock private WorkspaceService workspaceService; + + @InjectMocks private ProjectService projectService; + + private UUID workspaceId; + private UUID projectId; + private Workspace workspace; + private Project project; + private ProjectRequest request; + + @BeforeEach + void setUp() { + workspaceId = UUID.randomUUID(); + projectId = UUID.randomUUID(); + workspace = + Workspace.builder() + .id(workspaceId) + .name("Test Workspace") + .description("WS Desc") + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + project = + Project.builder() + .id(projectId) + .name("Test Project") + .description("Project Desc") + .workspace(workspace) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + request = new ProjectRequest("Test Project", "Project Desc"); + } + + @Test + void getProjectsShouldReturnEmptyList() { + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of()); + + List result = projectService.getProjects(workspaceId, null); + + assertTrue(result.isEmpty()); + verify(projectRepository).findAll(any(Specification.class)); + } + + @Test + void getProjectsShouldReturnList() { + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of(project)); + + List result = projectService.getProjects(workspaceId, null); + + assertEquals(1, result.size()); + assertEquals(projectId, result.getFirst().id()); + assertEquals("Test Project", result.getFirst().name()); + assertEquals(workspaceId, result.getFirst().workspaceId()); + } + + @Test + void getProjectsShouldFilterByName() { + when(projectRepository.findAll(any(Specification.class))).thenReturn(List.of(project)); + + List result = projectService.getProjects(workspaceId, "test"); + + assertEquals(1, result.size()); + assertEquals("Test Project", result.getFirst().name()); + verify(projectRepository).findAll(any(Specification.class)); + } + + @Test + void createProjectShouldSucceed() { + when(workspaceService.getWorkspace(workspaceId)).thenReturn(workspace); + when(projectRepository.existsByNameIgnoreCaseAndWorkspace_Id(request.name(), workspaceId)) + .thenReturn(false); + when(projectRepository.save(any(Project.class))).thenReturn(project); + + ProjectResponse result = projectService.createProject(workspaceId, request); + + assertEquals(projectId, result.id()); + assertEquals("Test Project", result.name()); + assertEquals(workspaceId, result.workspaceId()); + verify(workspaceService).getWorkspace(workspaceId); + verify(projectRepository).existsByNameIgnoreCaseAndWorkspace_Id(request.name(), workspaceId); + verify(projectRepository).save(any(Project.class)); + } + + @Test + void createProjectShouldThrowNotFoundWhenWorkspaceDoesNotExist() { + when(workspaceService.getWorkspace(workspaceId)) + .thenThrow(new NotFoundException("Workspace with id '" + workspaceId + "' not found")); + + NotFoundException ex = + assertThrows( + NotFoundException.class, () -> projectService.createProject(workspaceId, request)); + + assertTrue(ex.getMessage().contains(workspaceId.toString())); + verify(projectRepository, never()).save(any()); + } + + @Test + void createProjectShouldThrowConflictWhenNameExistsInWorkspace() { + when(workspaceService.getWorkspace(workspaceId)).thenReturn(workspace); + when(projectRepository.existsByNameIgnoreCaseAndWorkspace_Id(request.name(), workspaceId)) + .thenReturn(true); + + ConflictException ex = + assertThrows( + ConflictException.class, () -> projectService.createProject(workspaceId, request)); + + assertTrue(ex.getMessage().contains("Test Project")); + verify(projectRepository, never()).save(any()); + } + + @Test + void updateProjectShouldSucceed() { + when(projectRepository.findById(projectId)).thenReturn(Optional.of(project)); + when(projectRepository.save(any(Project.class))).thenReturn(project); + + var newRequest = new ProjectRequest("Updated Name", "Updated Desc"); + ProjectResponse result = projectService.updateProject(projectId, newRequest); + + assertEquals(projectId, result.id()); + assertEquals("Updated Name", result.name()); + verify(projectRepository).findById(projectId); + verify(projectRepository).save(any(Project.class)); + } + + @Test + void updateProjectShouldSucceedWhenNameUnchanged() { + when(projectRepository.findById(projectId)).thenReturn(Optional.of(project)); + when(projectRepository.save(any(Project.class))).thenReturn(project); + + ProjectResponse result = projectService.updateProject(projectId, request); + + assertEquals(projectId, result.id()); + verify(projectRepository).save(any(Project.class)); + } + + @Test + void updateProjectShouldThrowNotFound() { + when(projectRepository.findById(projectId)).thenReturn(Optional.empty()); + + NotFoundException ex = + assertThrows( + NotFoundException.class, () -> projectService.updateProject(projectId, request)); + + assertTrue(ex.getMessage().contains(projectId.toString())); + verify(projectRepository, never()).save(any()); + } + + @Test + void updateProjectShouldThrowConflictWhenNameExistsInWorkspace() { + var newRequest = new ProjectRequest("New Name", "desc"); + when(projectRepository.findById(projectId)).thenReturn(Optional.of(project)); + when(projectRepository.existsByNameIgnoreCaseAndWorkspace_Id("New Name", workspaceId)) + .thenReturn(true); + + ConflictException ex = + assertThrows( + ConflictException.class, () -> projectService.updateProject(projectId, newRequest)); + + assertTrue(ex.getMessage().contains("New Name")); + verify(projectRepository, never()).save(any()); + } + + @Test + void deleteProjectShouldSucceed() { + when(projectRepository.findById(projectId)).thenReturn(Optional.of(project)); + + projectService.deleteProject(projectId); + + verify(projectRepository).findById(projectId); + verify(projectRepository).delete(project); + } + + @Test + void deleteProjectShouldThrowNotFound() { + when(projectRepository.findById(projectId)).thenReturn(Optional.empty()); + + NotFoundException ex = + assertThrows(NotFoundException.class, () -> projectService.deleteProject(projectId)); + + assertTrue(ex.getMessage().contains(projectId.toString())); + verify(projectRepository, never()).delete((Project) any()); + } +} diff --git a/src/test/java/ai/decompile/workspace/service/WorkspaceServiceTest.java b/src/test/java/ai/decompile/workspace/service/WorkspaceServiceTest.java index 8dc7527..ad53027 100644 --- a/src/test/java/ai/decompile/workspace/service/WorkspaceServiceTest.java +++ b/src/test/java/ai/decompile/workspace/service/WorkspaceServiceTest.java @@ -43,6 +43,7 @@ class WorkspaceServiceTest { .description("Test Description") .createdAt(Instant.now()) .updatedAt(Instant.now()) + .projects(List.of()) .build(); request = new WorkspaceRequest("Test Workspace", "Test Description"); } @@ -66,6 +67,7 @@ class WorkspaceServiceTest { assertEquals(1, result.size()); assertEquals(id, result.getFirst().id()); assertEquals("Test Workspace", result.getFirst().name()); + assertEquals(0, result.getFirst().projectCount()); } @Test @@ -76,9 +78,32 @@ class WorkspaceServiceTest { assertEquals(1, result.size()); assertEquals("Test Workspace", result.getFirst().name()); + assertEquals(0, result.getFirst().projectCount()); verify(workspaceRepository).findAll(any(Specification.class)); } + @Test + void getWorkspaceByIdShouldReturnWorkspace() { + when(workspaceRepository.findById(id)).thenReturn(Optional.of(workspace)); + + WorkspaceResponse result = workspaceService.getWorkspaceById(id); + + assertEquals(id, result.id()); + assertEquals("Test Workspace", result.name()); + assertEquals(0, result.projectCount()); + verify(workspaceRepository).findById(id); + } + + @Test + void getWorkspaceByIdShouldThrowNotFound() { + when(workspaceRepository.findById(id)).thenReturn(Optional.empty()); + + NotFoundException ex = + assertThrows(NotFoundException.class, () -> workspaceService.getWorkspaceById(id)); + + assertTrue(ex.getMessage().contains(id.toString())); + } + @Test void createWorkspaceShouldSucceed() { when(workspaceRepository.existsByNameIgnoreCase(request.name())).thenReturn(false);