From 559f137a275576ef9ebeac51b43ad338a892e5b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Sat, 6 Jun 2026 22:09:13 -0300 Subject: [PATCH] feat(binary): implement CRUD operations for binaries --- .gitignore | 3 + .../controller/BinaryController.java | 79 +++++++ .../controller/ProjectController.java | 25 +- .../workspace/model/dto/BinaryResponse.java | 29 +++ .../workspace/model/dto/ProjectResponse.java | 6 +- .../model/dto/WorkspaceResponse.java | 3 +- .../workspace/model/entity/Binary.java | 48 ++++ .../workspace/model/entity/Project.java | 6 + .../workspace/model/entity/Workspace.java | 4 +- .../model/repository/BinaryRepository.java | 9 + .../specification/BinarySpecification.java | 19 ++ .../workspace/service/BinaryService.java | 89 ++++++++ .../workspace/service/ProjectService.java | 6 +- .../service/storage/FileResource.java | 5 + .../service/storage/FileStorage.java | 12 + .../service/storage/LocalFileStorage.java | 44 ++++ src/main/resources/application.yml | 8 + .../migration/V004__create_binaries_table.sql | 12 + .../controller/ProjectControllerTest.java | 19 +- .../controller/ProjectIntegrationTest.java | 17 +- .../controller/BinaryControllerTest.java | 167 ++++++++++++++ .../controller/BinaryIntegrationTest.java | 214 ++++++++++++++++++ .../workspace/service/BinaryServiceTest.java | 169 ++++++++++++++ src/test/resources/application-test.yml | 8 + 24 files changed, 964 insertions(+), 37 deletions(-) create mode 100644 src/main/java/ai/decompile/workspace/controller/BinaryController.java create mode 100644 src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java create mode 100644 src/main/java/ai/decompile/workspace/model/entity/Binary.java create mode 100644 src/main/java/ai/decompile/workspace/model/repository/BinaryRepository.java create mode 100644 src/main/java/ai/decompile/workspace/model/specification/BinarySpecification.java create mode 100644 src/main/java/ai/decompile/workspace/service/BinaryService.java create mode 100644 src/main/java/ai/decompile/workspace/service/storage/FileResource.java create mode 100644 src/main/java/ai/decompile/workspace/service/storage/FileStorage.java create mode 100644 src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java create mode 100644 src/main/resources/db/migration/V004__create_binaries_table.sql create mode 100644 src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java create mode 100644 src/test/java/ai/decompile/workspace/controller/BinaryIntegrationTest.java create mode 100644 src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java diff --git a/.gitignore b/.gitignore index 8dd4618..54c0a53 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ target/ +# Uploaded binaries +uploads/ + ### Intellij ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/src/main/java/ai/decompile/workspace/controller/BinaryController.java b/src/main/java/ai/decompile/workspace/controller/BinaryController.java new file mode 100644 index 0000000..1bb7bfe --- /dev/null +++ b/src/main/java/ai/decompile/workspace/controller/BinaryController.java @@ -0,0 +1,79 @@ +package ai.decompile.workspace.controller; + +import ai.decompile.workspace.model.dto.BinaryResponse; +import ai.decompile.workspace.service.BinaryService; +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.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class BinaryController { + private final BinaryService binaryService; + + @Operation( + summary = "List binaries of a project", + description = + "Retrieve all binaries belonging to a project, optionally filtered by filename.", + tags = {"Binary"}, + operationId = "getBinaries") + @GetMapping("/projects/{projectId}/binaries") + public List getBinaries( + @PathVariable UUID projectId, + @Parameter( + description = "Filter binaries whose filename contains this value (case-insensitive)") + @RequestParam(required = false) + String name) { + return binaryService.getBinaries(projectId, name); + } + + @Operation( + summary = "Upload a binary", + description = "Upload a binary file to a project.", + tags = {"Binary"}, + operationId = "uploadBinary") + @PostMapping( + value = "/projects/{projectId}/binaries", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public BinaryResponse uploadBinary( + @PathVariable UUID projectId, @RequestPart MultipartFile file) { + return binaryService.uploadBinary(projectId, file); + } + + @Operation( + summary = "Delete a binary", + description = "Delete a binary and its file.", + tags = {"Binary"}, + operationId = "deleteBinary") + @DeleteMapping("/binaries/{binaryId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteBinary(@PathVariable UUID binaryId) { + binaryService.deleteBinary(binaryId); + } + + @Operation( + summary = "Download a binary", + description = "Download the binary file.", + tags = {"Binary"}, + operationId = "downloadBinary") + @GetMapping("/binaries/{binaryId}/download") + public ResponseEntity downloadBinary(@PathVariable UUID binaryId) { + var resource = binaryService.downloadBinary(binaryId); + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.filename() + "\"") + .contentLength(resource.contentLength()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new InputStreamResource(resource.inputStream())); + } +} diff --git a/src/main/java/ai/decompile/workspace/controller/ProjectController.java b/src/main/java/ai/decompile/workspace/controller/ProjectController.java index 3d47fa9..180fcef 100644 --- a/src/main/java/ai/decompile/workspace/controller/ProjectController.java +++ b/src/main/java/ai/decompile/workspace/controller/ProjectController.java @@ -11,7 +11,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/workspaces/{workspaceId}/projects") @RequiredArgsConstructor public class ProjectController { private final ProjectService projectService; @@ -21,7 +20,7 @@ public class ProjectController { description = "Retrieve all projects belonging to a workspace, optionally filtered by name.", tags = {"Project"}, operationId = "getProjects") - @GetMapping + @GetMapping("/workspaces/{workspaceId}/projects") public List getProjects( @PathVariable UUID workspaceId, @Parameter(description = "Filter projects whose name contains this value (case-insensitive)") @@ -30,12 +29,22 @@ public class ProjectController { return projectService.getProjects(workspaceId, name); } + @Operation( + summary = "Get a project", + description = "Retrieve a single project by ID.", + tags = {"Project"}, + operationId = "getProject") + @GetMapping("/projects/{projectId}") + public ProjectResponse getProject(@PathVariable UUID projectId) { + return projectService.getProjectById(projectId); + } + @Operation( summary = "Create a project", description = "Create a project in a workspace.", tags = {"Project"}, operationId = "createProject") - @PostMapping + @PostMapping("/workspaces/{workspaceId}/projects") public ProjectResponse createProject( @PathVariable UUID workspaceId, @RequestBody ProjectRequest request) { return projectService.createProject(workspaceId, request); @@ -46,11 +55,9 @@ public class ProjectController { description = "Update a project's name and description.", tags = {"Project"}, operationId = "updateProject") - @PutMapping("/{projectId}") + @PutMapping("/projects/{projectId}") public ProjectResponse updateProject( - @PathVariable UUID workspaceId, - @PathVariable UUID projectId, - @RequestBody ProjectRequest request) { + @PathVariable UUID projectId, @RequestBody ProjectRequest request) { return projectService.updateProject(projectId, request); } @@ -59,8 +66,8 @@ public class ProjectController { description = "Delete a project.", tags = {"Project"}, operationId = "deleteProject") - @DeleteMapping("/{projectId}") - public void deleteProject(@PathVariable UUID workspaceId, @PathVariable UUID projectId) { + @DeleteMapping("/projects/{projectId}") + public void deleteProject(@PathVariable UUID projectId) { projectService.deleteProject(projectId); } } diff --git a/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java b/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java new file mode 100644 index 0000000..c441d54 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java @@ -0,0 +1,29 @@ +package ai.decompile.workspace.model.dto; + +import ai.decompile.workspace.model.entity.Binary; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.UUID; + +public record BinaryResponse( + @NotNull UUID id, + @NotNull UUID projectId, + @NotBlank String filename, + long fileSize, + String format, + String architecture, + @NotBlank String filePath, + @NotNull Instant updatedAt) { + public static BinaryResponse from(Binary binary) { + return new BinaryResponse( + binary.getId(), + binary.getProject().getId(), + binary.getFilename(), + binary.getFileSize(), + binary.getFormat(), + binary.getArchitecture(), + binary.getFilePath(), + binary.getUpdatedAt()); + } +} diff --git a/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java b/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java index 4e0ac01..c71edc0 100644 --- a/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java +++ b/src/main/java/ai/decompile/workspace/model/dto/ProjectResponse.java @@ -11,13 +11,15 @@ public record ProjectResponse( @NotNull UUID workspaceId, @NotBlank String name, String description, - @NotNull Instant updatedAt) { + @NotNull Instant updatedAt, + int binaryCount) { public static ProjectResponse from(Project project) { return new ProjectResponse( project.getId(), project.getWorkspace().getId(), project.getName(), project.getDescription(), - project.getUpdatedAt()); + project.getUpdatedAt(), + project.getBinaries().size()); } } 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 e9959bd..c9c1afc 100644 --- a/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java +++ b/src/main/java/ai/decompile/workspace/model/dto/WorkspaceResponse.java @@ -13,12 +13,11 @@ public record WorkspaceResponse( @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(), - projects != null ? projects.size() : 0); + workspace.getProjects().size()); } } diff --git a/src/main/java/ai/decompile/workspace/model/entity/Binary.java b/src/main/java/ai/decompile/workspace/model/entity/Binary.java new file mode 100644 index 0000000..f11a530 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/entity/Binary.java @@ -0,0 +1,48 @@ +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 = "binaries") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Binary { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String filename; + + @Column(name = "file_size", nullable = false) + private long fileSize; + + @Column(length = 20) + private String format; + + @Column(length = 20) + private String architecture; + + @Column(name = "file_path", nullable = false, length = 500) + private String filePath; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @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/Project.java b/src/main/java/ai/decompile/workspace/model/entity/Project.java index 180029e..aaf13c4 100644 --- a/src/main/java/ai/decompile/workspace/model/entity/Project.java +++ b/src/main/java/ai/decompile/workspace/model/entity/Project.java @@ -2,6 +2,8 @@ package ai.decompile.workspace.model.entity; import jakarta.persistence.*; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.*; import org.hibernate.annotations.CreationTimestamp; @@ -29,6 +31,10 @@ public class Project { @JoinColumn(name = "workspace_id", nullable = false) private Workspace workspace; + @Builder.Default + @OneToMany(mappedBy = "project", cascade = CascadeType.ALL, orphanRemoval = true) + private List binaries = new ArrayList<>(); + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; 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 3786f60..ed9fa19 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.ArrayList; import java.util.List; import java.util.UUID; import lombok.*; @@ -34,6 +35,7 @@ public class Workspace { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + @Builder.Default @OneToMany(mappedBy = "workspace", cascade = CascadeType.ALL, orphanRemoval = true) - private List projects; + private List projects = new ArrayList<>(); } diff --git a/src/main/java/ai/decompile/workspace/model/repository/BinaryRepository.java b/src/main/java/ai/decompile/workspace/model/repository/BinaryRepository.java new file mode 100644 index 0000000..b5aaead --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/repository/BinaryRepository.java @@ -0,0 +1,9 @@ +package ai.decompile.workspace.model.repository; + +import ai.decompile.workspace.model.entity.Binary; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface BinaryRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/ai/decompile/workspace/model/specification/BinarySpecification.java b/src/main/java/ai/decompile/workspace/model/specification/BinarySpecification.java new file mode 100644 index 0000000..2faf731 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/model/specification/BinarySpecification.java @@ -0,0 +1,19 @@ +package ai.decompile.workspace.model.specification; + +import ai.decompile.workspace.model.entity.Binary; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.Specification; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BinarySpecification { + public static Specification filenameContains(String filename) { + return (root, _, cb) -> + cb.like(cb.lower(root.get("filename")), "%" + filename.toLowerCase() + "%"); + } + + public static Specification hasProjectId(UUID projectId) { + return (root, _, cb) -> cb.equal(root.get("project").get("id"), projectId); + } +} diff --git a/src/main/java/ai/decompile/workspace/service/BinaryService.java b/src/main/java/ai/decompile/workspace/service/BinaryService.java new file mode 100644 index 0000000..482d908 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/service/BinaryService.java @@ -0,0 +1,89 @@ +package ai.decompile.workspace.service; + +import ai.decompile.exception.NotFoundException; +import ai.decompile.workspace.model.dto.BinaryResponse; +import ai.decompile.workspace.model.entity.Binary; +import ai.decompile.workspace.model.repository.BinaryRepository; +import ai.decompile.workspace.model.specification.BinarySpecification; +import ai.decompile.workspace.service.storage.FileResource; +import ai.decompile.workspace.service.storage.FileStorage; +import java.io.IOException; +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; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class BinaryService { + private final ProjectService projectService; + private final BinaryRepository binaryRepository; + private final FileStorage fileStorage; + + public List getBinaries(UUID projectId, String filename) { + projectService.getProject(projectId); + + Specification spec = BinarySpecification.hasProjectId(projectId); + if (StringUtils.isNotBlank(filename)) { + spec = spec.and(BinarySpecification.filenameContains(filename)); + } + + return binaryRepository.findAll(spec).stream().map(BinaryResponse::from).toList(); + } + + @Transactional + public BinaryResponse uploadBinary(UUID projectId, MultipartFile file) { + var project = projectService.getProject(projectId); + + String storedPath; + try { + storedPath = fileStorage.store(file.getInputStream(), file.getOriginalFilename()); + } catch (IOException e) { + throw new RuntimeException("Failed to store file", e); + } + + var binary = + binaryRepository.save( + Binary.builder() + .filename(file.getOriginalFilename()) + .fileSize(file.getSize()) + .filePath(storedPath) + .project(project) + .build()); + + return BinaryResponse.from(binary); + } + + @Transactional + public void deleteBinary(UUID binaryId) { + var binary = getBinary(binaryId); + + try { + fileStorage.delete(binary.getFilePath()); + } catch (IOException e) { + throw new RuntimeException("Failed to delete file", e); + } + + binaryRepository.delete(binary); + } + + public FileResource downloadBinary(UUID binaryId) { + var binary = getBinary(binaryId); + try { + return new FileResource( + fileStorage.read(binary.getFilePath()), binary.getFilename(), binary.getFileSize()); + } catch (IOException e) { + throw new RuntimeException("Failed to read file", e); + } + } + + private Binary getBinary(UUID binaryId) { + return binaryRepository + .findById(binaryId) + .orElseThrow(() -> new NotFoundException("Binary with id '" + binaryId + "' not found")); + } +} diff --git a/src/main/java/ai/decompile/workspace/service/ProjectService.java b/src/main/java/ai/decompile/workspace/service/ProjectService.java index e83ba67..4a92b23 100644 --- a/src/main/java/ai/decompile/workspace/service/ProjectService.java +++ b/src/main/java/ai/decompile/workspace/service/ProjectService.java @@ -29,6 +29,10 @@ public class ProjectService { return projectRepository.findAll(spec).stream().map(ProjectResponse::from).toList(); } + public ProjectResponse getProjectById(UUID projectId) { + return ProjectResponse.from(getProject(projectId)); + } + public ProjectResponse createProject(UUID workspaceId, ProjectRequest request) { var workspace = workspaceService.getWorkspace(workspaceId); @@ -69,7 +73,7 @@ public class ProjectService { projectRepository.delete(project); } - private Project getProject(UUID projectId) { + 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/storage/FileResource.java b/src/main/java/ai/decompile/workspace/service/storage/FileResource.java new file mode 100644 index 0000000..7980ff7 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/service/storage/FileResource.java @@ -0,0 +1,5 @@ +package ai.decompile.workspace.service.storage; + +import java.io.InputStream; + +public record FileResource(InputStream inputStream, String filename, long contentLength) {} diff --git a/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java b/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java new file mode 100644 index 0000000..27e3562 --- /dev/null +++ b/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java @@ -0,0 +1,12 @@ +package ai.decompile.workspace.service.storage; + +import java.io.IOException; +import java.io.InputStream; + +public interface FileStorage { + String store(InputStream inputStream, String filename) throws IOException; + + void delete(String path) throws IOException; + + InputStream read(String path) throws IOException; +} diff --git a/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java b/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java new file mode 100644 index 0000000..7cd320d --- /dev/null +++ b/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java @@ -0,0 +1,44 @@ +package ai.decompile.workspace.service.storage; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Component +@Primary +public class LocalFileStorage implements FileStorage { + private final Path baseDir; + + public LocalFileStorage(@Value("${app.storage.upload-dir}") String uploadDir) { + this.baseDir = Path.of(uploadDir).toAbsolutePath().normalize(); + } + + @Override + public String store(InputStream inputStream, String filename) throws IOException { + Files.createDirectories(baseDir); + + var storedName = UUID.randomUUID() + "_" + filename; + var targetPath = baseDir.resolve(storedName); + Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + + return storedName; + } + + @Override + public void delete(String path) throws IOException { + var filePath = baseDir.resolve(path); + Files.deleteIfExists(filePath); + } + + @Override + public InputStream read(String path) throws IOException { + var filePath = baseDir.resolve(path); + return Files.newInputStream(filePath); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 51aa7d6..baeb96d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,14 @@ spring: ddl-auto: validate flyway: enabled: true + servlet: + multipart: + max-file-size: 500MB + max-request-size: 500MB + +app: + storage: + upload-dir: ./uploads/binaries logging: level: diff --git a/src/main/resources/db/migration/V004__create_binaries_table.sql b/src/main/resources/db/migration/V004__create_binaries_table.sql new file mode 100644 index 0000000..2f94416 --- /dev/null +++ b/src/main/resources/db/migration/V004__create_binaries_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE binaries +( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + format VARCHAR(20), + architecture VARCHAR(20), + file_path VARCHAR(500) NOT NULL, + 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 index 8f1ed82..2b9d5f6 100644 --- a/src/test/java/ai/decompile/project/controller/ProjectControllerTest.java +++ b/src/test/java/ai/decompile/project/controller/ProjectControllerTest.java @@ -38,7 +38,7 @@ class ProjectControllerTest { var workspaceId = UUID.randomUUID(); var projectId = UUID.randomUUID(); var response = - new ProjectResponse(projectId, workspaceId, "Test Project", "Desc", Instant.now()); + new ProjectResponse(projectId, workspaceId, "Test Project", "Desc", Instant.now(), 0); when(projectService.getProjects(eq(workspaceId), isNull())).thenReturn(List.of(response)); mockMvc @@ -66,7 +66,7 @@ class ProjectControllerTest { var workspaceId = UUID.randomUUID(); var projectId = UUID.randomUUID(); var response = - new ProjectResponse(projectId, workspaceId, "Filtered Project", "Desc", Instant.now()); + new ProjectResponse(projectId, workspaceId, "Filtered Project", "Desc", Instant.now(), 0); when(projectService.getProjects(eq(workspaceId), eq("filter"))).thenReturn(List.of(response)); mockMvc @@ -82,7 +82,8 @@ class ProjectControllerTest { var projectId = UUID.randomUUID(); var request = new ProjectRequest("New Project", "A description"); var response = - new ProjectResponse(projectId, workspaceId, "New Project", "A description", Instant.now()); + new ProjectResponse( + projectId, workspaceId, "New Project", "A description", Instant.now(), 0); when(projectService.createProject(eq(workspaceId), any())).thenReturn(response); mockMvc @@ -138,12 +139,12 @@ class ProjectControllerTest { var projectId = UUID.randomUUID(); var request = new ProjectRequest("Updated", "Updated desc"); var response = - new ProjectResponse(projectId, workspaceId, "Updated", "Updated desc", Instant.now()); + new ProjectResponse(projectId, workspaceId, "Updated", "Updated desc", Instant.now(), 0); when(projectService.updateProject(eq(projectId), any())).thenReturn(response); mockMvc .perform( - put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId) + put("/projects/{projectId}", projectId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) @@ -160,7 +161,7 @@ class ProjectControllerTest { mockMvc .perform( - put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId) + put("/projects/{projectId}", projectId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) @@ -174,9 +175,7 @@ class ProjectControllerTest { var projectId = UUID.randomUUID(); doNothing().when(projectService).deleteProject(projectId); - mockMvc - .perform(delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId)) - .andExpect(status().isOk()); + mockMvc.perform(delete("/projects/{projectId}", projectId)).andExpect(status().isOk()); } @Test @@ -188,7 +187,7 @@ class ProjectControllerTest { .deleteProject(projectId); mockMvc - .perform(delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, projectId)) + .perform(delete("/projects/{projectId}", 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 index 5102683..cdd903c 100644 --- a/src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java +++ b/src/test/java/ai/decompile/project/controller/ProjectIntegrationTest.java @@ -96,21 +96,15 @@ class ProjectIntegrationTest { var updateRequest = new ProjectRequest("CRUD Updated", "Updated Description"); mockMvc .perform( - put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, created.id()) + put("/projects/{projectId}", 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("/projects/{projectId}", created.id())).andExpect(status().isOk()); - mockMvc - .perform( - delete("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, created.id())) - .andExpect(status().isNotFound()); + mockMvc.perform(delete("/projects/{projectId}", created.id())).andExpect(status().isNotFound()); } @Test @@ -180,15 +174,14 @@ class ProjectIntegrationTest { mockMvc .perform( - put("/workspaces/{workspaceId}/projects/{projectId}", workspaceId, nonExistentId) + put("/projects/{projectId}", 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)) + .perform(delete("/projects/{projectId}", nonExistentId)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)); } diff --git a/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java b/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java new file mode 100644 index 0000000..8c0b1b1 --- /dev/null +++ b/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java @@ -0,0 +1,167 @@ +package ai.decompile.workspace.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.NotFoundException; +import ai.decompile.workspace.model.dto.BinaryResponse; +import ai.decompile.workspace.service.BinaryService; +import ai.decompile.workspace.service.storage.FileResource; +import java.io.ByteArrayInputStream; +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.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(BinaryController.class) +class BinaryControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private BinaryService binaryService; + + @Test + void getBinariesShouldReturn200WithList() throws Exception { + var projectId = UUID.randomUUID(); + var binaryId = UUID.randomUUID(); + var response = + new BinaryResponse( + binaryId, projectId, "test.exe", 1024L, null, null, "abc_test.exe", Instant.now()); + when(binaryService.getBinaries(eq(projectId), isNull())).thenReturn(List.of(response)); + + mockMvc + .perform(get("/projects/{projectId}/binaries", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(binaryId.toString())) + .andExpect(jsonPath("$[0].filename").value("test.exe")) + .andExpect(jsonPath("$[0].fileSize").value(1024)); + } + + @Test + void getBinariesShouldReturn200WithEmptyList() throws Exception { + var projectId = UUID.randomUUID(); + when(binaryService.getBinaries(eq(projectId), isNull())).thenReturn(List.of()); + + mockMvc + .perform(get("/projects/{projectId}/binaries", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void getBinariesShouldFilterByName() throws Exception { + var projectId = UUID.randomUUID(); + var binaryId = UUID.randomUUID(); + var response = + new BinaryResponse( + binaryId, + projectId, + "filtered.exe", + 512L, + null, + null, + "abc_filtered.exe", + Instant.now()); + when(binaryService.getBinaries(eq(projectId), eq("filter"))).thenReturn(List.of(response)); + + mockMvc + .perform(get("/projects/{projectId}/binaries", projectId).param("name", "filter")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].filename").value("filtered.exe")); + } + + @Test + void getBinariesShouldReturn404WhenProjectNotFound() throws Exception { + var projectId = UUID.randomUUID(); + when(binaryService.getBinaries(eq(projectId), isNull())) + .thenThrow(new NotFoundException("Project with id '" + projectId + "' not found")); + + mockMvc + .perform(get("/projects/{projectId}/binaries", projectId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void uploadBinaryShouldReturn201() throws Exception { + var projectId = UUID.randomUUID(); + var binaryId = UUID.randomUUID(); + var file = + new MockMultipartFile("file", "test.exe", "application/octet-stream", new byte[] {1, 2, 3}); + var response = + new BinaryResponse( + binaryId, projectId, "test.exe", 3L, null, null, "abc_test.exe", Instant.now()); + when(binaryService.uploadBinary(eq(projectId), any())).thenReturn(response); + + mockMvc + .perform(multipart("/projects/{projectId}/binaries", projectId).file(file)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(binaryId.toString())) + .andExpect(jsonPath("$.filename").value("test.exe")) + .andExpect(jsonPath("$.fileSize").value(3)); + } + + @Test + void deleteBinaryShouldReturn204() throws Exception { + var projectId = UUID.randomUUID(); + var binaryId = UUID.randomUUID(); + doNothing().when(binaryService).deleteBinary(binaryId); + + mockMvc.perform(delete("/binaries/{binaryId}", binaryId)).andExpect(status().isNoContent()); + } + + @Test + void deleteBinaryShouldReturn404OnNotFound() throws Exception { + var projectId = UUID.randomUUID(); + var binaryId = UUID.randomUUID(); + doThrow(new NotFoundException("Binary with id '" + binaryId + "' not found")) + .when(binaryService) + .deleteBinary(binaryId); + + mockMvc + .perform(delete("/binaries/{binaryId}", binaryId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void downloadBinaryShouldReturn200() throws Exception { + var binaryId = UUID.randomUUID(); + var content = new byte[] {1, 2, 3, 4}; + var resource = new FileResource(new ByteArrayInputStream(content), "test.exe", 4L); + when(binaryService.downloadBinary(binaryId)).thenReturn(resource); + + mockMvc + .perform(get("/binaries/{binaryId}/download", binaryId)) + .andExpect(status().isOk()) + .andExpect( + header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"test.exe\"")) + .andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, 4)) + .andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM)) + .andExpect(content().bytes(content)); + } + + @Test + void downloadBinaryShouldReturn404OnNotFound() throws Exception { + var binaryId = UUID.randomUUID(); + when(binaryService.downloadBinary(binaryId)) + .thenThrow(new NotFoundException("Binary with id '" + binaryId + "' not found")); + + mockMvc + .perform(get("/binaries/{binaryId}/download", binaryId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } +} diff --git a/src/test/java/ai/decompile/workspace/controller/BinaryIntegrationTest.java b/src/test/java/ai/decompile/workspace/controller/BinaryIntegrationTest.java new file mode 100644 index 0000000..70a20f4 --- /dev/null +++ b/src/test/java/ai/decompile/workspace/controller/BinaryIntegrationTest.java @@ -0,0 +1,214 @@ +package ai.decompile.workspace.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.BinaryResponse; +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.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +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 BinaryIntegrationTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private EntityManager entityManager; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void fullCrudFlow() throws Exception { + var workspaceId = createWorkspace("Binary WS"); + var projectId = createProject(workspaceId, "Binary Project"); + + mockMvc + .perform(get("/projects/{pid}/binaries", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + + var file = + new MockMultipartFile( + "file", + "binary.exe", + "application/octet-stream", + new byte[] {0x01, 0x02, 0x03, 0x04, 0x05}); + + var createdJson = + mockMvc + .perform(multipart("/projects/{pid}/binaries", projectId).file(file)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.filename").value("binary.exe")) + .andExpect(jsonPath("$.fileSize").value(5)) + .andExpect(jsonPath("$.format").isEmpty()) + .andExpect(jsonPath("$.architecture").isEmpty()) + .andReturn() + .getResponse() + .getContentAsString(); + + var created = objectMapper.readValue(createdJson, BinaryResponse.class); + + mockMvc + .perform(get("/projects/{pid}/binaries", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].filename").value("binary.exe")) + .andExpect(jsonPath("$[0].fileSize").value(5)); + + mockMvc + .perform(get("/projects/{pid}/binaries", projectId).param("name", "binary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + + mockMvc + .perform(get("/projects/{pid}/binaries", projectId).param("name", "nonexistent")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + mockMvc.perform(delete("/binaries/{bid}", created.id())).andExpect(status().isNoContent()); + + mockMvc + .perform(get("/projects/{pid}/binaries", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void uploadBinaryToNonexistentProjectShouldReturn404() throws Exception { + var fakeProjectId = UUID.randomUUID(); + var file = + new MockMultipartFile("file", "test.exe", "application/octet-stream", new byte[] {1, 2}); + + mockMvc + .perform(multipart("/projects/{pid}/binaries", fakeProjectId).file(file)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void deleteNonexistentBinaryShouldReturn404() throws Exception { + var workspaceId = createWorkspace("Binary WS3"); + var projectId = createProject(workspaceId, "Binary Project 3"); + var fakeBinaryId = UUID.randomUUID(); + + mockMvc + .perform(delete("/binaries/{bid}", fakeBinaryId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + @Test + void projectResponseShouldIncludeBinaryCount() throws Exception { + var workspaceId = createWorkspace("Count WS"); + var projectId = createProject(workspaceId, "Count Project"); + + mockMvc + .perform(get("/projects/{pid}", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.binaryCount").value(0)); + + var file1 = new MockMultipartFile("file", "a.exe", "application/octet-stream", new byte[] {1}); + mockMvc + .perform(multipart("/projects/{pid}/binaries", projectId).file(file1)) + .andExpect(status().isCreated()); + + var file2 = new MockMultipartFile("file", "b.exe", "application/octet-stream", new byte[] {2}); + mockMvc + .perform(multipart("/projects/{pid}/binaries", projectId).file(file2)) + .andExpect(status().isCreated()); + + entityManager.flush(); + entityManager.clear(); + + mockMvc + .perform(get("/projects/{pid}", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.binaryCount").value(2)); + } + + @Test + void downloadBinaryShouldReturnFile() throws Exception { + var workspaceId = createWorkspace("Download WS"); + var projectId = createProject(workspaceId, "Download Project"); + + var content = new byte[] {0x01, 0x02, 0x03}; + var file = new MockMultipartFile("file", "download.bin", "application/octet-stream", content); + + var createdJson = + mockMvc + .perform(multipart("/projects/{pid}/binaries", projectId).file(file)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + var created = objectMapper.readValue(createdJson, BinaryResponse.class); + + mockMvc + .perform(get("/binaries/{bid}/download", created.id())) + .andExpect(status().isOk()) + .andExpect( + header() + .string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"download.bin\"")) + .andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, 3)) + .andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM)) + .andExpect(content().bytes(content)); + } + + @Test + void downloadBinaryShouldReturn404OnNotFound() throws Exception { + var fakeBinaryId = UUID.randomUUID(); + + mockMvc + .perform(get("/binaries/{bid}/download", fakeBinaryId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)); + } + + 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(); + } + + private UUID createProject(UUID workspaceId, String name) throws Exception { + var request = new ProjectRequest(name, "desc"); + var json = + mockMvc + .perform( + post("/workspaces/{workspaceId}/projects", workspaceId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + return objectMapper.readValue(json, ProjectResponse.class).id(); + } +} diff --git a/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java b/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java new file mode 100644 index 0000000..e04ed8b --- /dev/null +++ b/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java @@ -0,0 +1,169 @@ +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.NotFoundException; +import ai.decompile.workspace.model.dto.BinaryResponse; +import ai.decompile.workspace.model.entity.Binary; +import ai.decompile.workspace.model.entity.Project; +import ai.decompile.workspace.model.entity.Workspace; +import ai.decompile.workspace.model.repository.BinaryRepository; +import ai.decompile.workspace.service.storage.FileStorage; +import java.io.IOException; +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; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +class BinaryServiceTest { + + @Mock private ProjectService projectService; + + @Mock private BinaryRepository binaryRepository; + + @Mock private FileStorage fileStorage; + + @InjectMocks private BinaryService binaryService; + + private UUID projectId; + private UUID binaryId; + private Project project; + private Binary binary; + private byte[] fileContent; + + @BeforeEach + void setUp() { + projectId = UUID.randomUUID(); + binaryId = UUID.randomUUID(); + var workspace = + Workspace.builder() + .id(UUID.randomUUID()) + .name("WS") + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + project = + Project.builder() + .id(projectId) + .name("Test Project") + .workspace(workspace) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + binary = + Binary.builder() + .id(binaryId) + .filename("test.bin") + .fileSize(100L) + .filePath("abc_test.bin") + .project(project) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + fileContent = new byte[] {1, 2, 3, 4}; + project.setBinaries(List.of(binary)); + } + + @Test + void getBinariesShouldReturnEmptyList() { + when(projectService.getProject(projectId)).thenReturn(project); + when(binaryRepository.findAll(any(Specification.class))).thenReturn(List.of()); + + List result = binaryService.getBinaries(projectId, null); + + assertTrue(result.isEmpty()); + verify(projectService).getProject(projectId); + verify(binaryRepository).findAll(any(Specification.class)); + } + + @Test + void getBinariesShouldReturnList() { + when(projectService.getProject(projectId)).thenReturn(project); + when(binaryRepository.findAll(any(Specification.class))).thenReturn(List.of(binary)); + + List result = binaryService.getBinaries(projectId, null); + + assertEquals(1, result.size()); + assertEquals(binaryId, result.getFirst().id()); + assertEquals("test.bin", result.getFirst().filename()); + assertEquals(projectId, result.getFirst().projectId()); + assertEquals(100L, result.getFirst().fileSize()); + } + + @Test + void getBinariesShouldFilterByFilename() { + when(projectService.getProject(projectId)).thenReturn(project); + when(binaryRepository.findAll(any(Specification.class))).thenReturn(List.of(binary)); + + List result = binaryService.getBinaries(projectId, "test"); + + assertEquals(1, result.size()); + assertEquals("test.bin", result.getFirst().filename()); + verify(binaryRepository).findAll(any(Specification.class)); + } + + @Test + void getBinariesShouldThrowNotFoundWhenProjectDoesNotExist() { + when(projectService.getProject(projectId)) + .thenThrow(new NotFoundException("Project with id '" + projectId + "' not found")); + + assertThrows(NotFoundException.class, () -> binaryService.getBinaries(projectId, null)); + verify(binaryRepository, never()).findAll(any(Specification.class)); + } + + @Test + void uploadBinaryShouldSucceed() throws IOException { + var file = new MockMultipartFile("file", "test.exe", "application/octet-stream", fileContent); + when(projectService.getProject(projectId)).thenReturn(project); + when(fileStorage.store(any(), eq("test.exe"))).thenReturn("stored_test.exe"); + when(binaryRepository.save(any(Binary.class))).thenReturn(binary); + + BinaryResponse result = binaryService.uploadBinary(projectId, file); + + assertEquals(binaryId, result.id()); + assertEquals("test.bin", result.filename()); + verify(projectService).getProject(projectId); + verify(fileStorage).store(any(), eq("test.exe")); + verify(binaryRepository).save(any(Binary.class)); + } + + @Test + void uploadBinaryShouldThrowRuntimeExceptionOnStorageFailure() throws IOException { + var file = new MockMultipartFile("file", "test.exe", "application/octet-stream", fileContent); + when(projectService.getProject(projectId)).thenReturn(project); + when(fileStorage.store(any(), eq("test.exe"))).thenThrow(new IOException("disk full")); + + assertThrows(RuntimeException.class, () -> binaryService.uploadBinary(projectId, file)); + verify(binaryRepository, never()).save(any()); + } + + @Test + void deleteBinaryShouldSucceed() throws IOException { + when(binaryRepository.findById(binaryId)).thenReturn(Optional.of(binary)); + + binaryService.deleteBinary(binaryId); + + verify(fileStorage).delete(binary.getFilePath()); + verify(binaryRepository).delete(binary); + } + + @Test + void deleteBinaryShouldThrowNotFound() throws IOException { + when(binaryRepository.findById(binaryId)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> binaryService.deleteBinary(binaryId)); + verify(fileStorage, never()).delete(any()); + verify(binaryRepository, never()).delete((Binary) any()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 95e7d17..8111514 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,3 +9,11 @@ spring: ddl-auto: create-drop flyway: enabled: false + servlet: + multipart: + max-file-size: 500MB + max-request-size: 500MB + +app: + storage: + upload-dir: /tmp/decompile-ai-test/binaries