feat(binary): implement CRUD operations for binaries

This commit is contained in:
Rodrigo Verdiani 2026-06-06 22:09:13 -03:00
parent 82074add45
commit 559f137a27
24 changed files with 964 additions and 37 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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<BinaryResponse> 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<InputStreamResource> 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()));
}
}

View File

@ -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<ProjectResponse> 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);
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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<Binary> binaries = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

View File

@ -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<Project> projects;
private List<Project> projects = new ArrayList<>();
}

View File

@ -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<Binary, UUID>, JpaSpecificationExecutor<Binary> {}

View File

@ -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<Binary> filenameContains(String filename) {
return (root, _, cb) ->
cb.like(cb.lower(root.get("filename")), "%" + filename.toLowerCase() + "%");
}
public static Specification<Binary> hasProjectId(UUID projectId) {
return (root, _, cb) -> cb.equal(root.get("project").get("id"), projectId);
}
}

View File

@ -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<BinaryResponse> getBinaries(UUID projectId, String filename) {
projectService.getProject(projectId);
Specification<Binary> 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"));
}
}

View File

@ -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"));

View File

@ -0,0 +1,5 @@
package ai.decompile.workspace.service.storage;
import java.io.InputStream;
public record FileResource(InputStream inputStream, String filename, long contentLength) {}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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:

View File

@ -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
);

View File

@ -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"));

View File

@ -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));
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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<BinaryResponse> 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<BinaryResponse> 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<BinaryResponse> 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());
}
}

View File

@ -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