feat(back-end): integration of clamAVS
This commit is contained in:
@@ -25,6 +25,7 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'xyz.capybara:clamav-client:2.1.2'
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
|||||||
@@ -28,20 +28,23 @@ public class OrderController {
|
|||||||
private final QuoteSessionRepository quoteSessionRepo;
|
private final QuoteSessionRepository quoteSessionRepo;
|
||||||
private final QuoteLineItemRepository quoteLineItemRepo;
|
private final QuoteLineItemRepository quoteLineItemRepo;
|
||||||
private final CustomerRepository customerRepo;
|
private final CustomerRepository customerRepo;
|
||||||
|
private final com.printcalculator.service.StorageService storageService;
|
||||||
|
|
||||||
// TODO: Inject Storage Service or use a base path property
|
// TODO: Inject Storage Service or use a base path property
|
||||||
private static final String STORAGE_ROOT = "storage_orders";
|
// private static final String STORAGE_ROOT = "storage_orders";
|
||||||
|
|
||||||
public OrderController(OrderRepository orderRepo,
|
public OrderController(OrderRepository orderRepo,
|
||||||
OrderItemRepository orderItemRepo,
|
OrderItemRepository orderItemRepo,
|
||||||
QuoteSessionRepository quoteSessionRepo,
|
QuoteSessionRepository quoteSessionRepo,
|
||||||
QuoteLineItemRepository quoteLineItemRepo,
|
QuoteLineItemRepository quoteLineItemRepo,
|
||||||
CustomerRepository customerRepo) {
|
CustomerRepository customerRepo,
|
||||||
|
com.printcalculator.service.StorageService storageService) {
|
||||||
this.orderRepo = orderRepo;
|
this.orderRepo = orderRepo;
|
||||||
this.orderItemRepo = orderItemRepo;
|
this.orderItemRepo = orderItemRepo;
|
||||||
this.quoteSessionRepo = quoteSessionRepo;
|
this.quoteSessionRepo = quoteSessionRepo;
|
||||||
this.quoteLineItemRepo = quoteLineItemRepo;
|
this.quoteLineItemRepo = quoteLineItemRepo;
|
||||||
this.customerRepo = customerRepo;
|
this.customerRepo = customerRepo;
|
||||||
|
this.storageService = storageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +72,9 @@ public class OrderController {
|
|||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
Customer newC = new Customer();
|
Customer newC = new Customer();
|
||||||
newC.setEmail(request.getCustomer().getEmail());
|
newC.setEmail(request.getCustomer().getEmail());
|
||||||
|
newC.setCustomerType(request.getCustomer().getCustomerType());
|
||||||
newC.setCreatedAt(OffsetDateTime.now());
|
newC.setCreatedAt(OffsetDateTime.now());
|
||||||
|
newC.setUpdatedAt(OffsetDateTime.now());
|
||||||
return customerRepo.save(newC);
|
return customerRepo.save(newC);
|
||||||
});
|
});
|
||||||
// Update customer details?
|
// Update customer details?
|
||||||
@@ -135,6 +140,13 @@ public class OrderController {
|
|||||||
|
|
||||||
BigDecimal subtotal = BigDecimal.ZERO;
|
BigDecimal subtotal = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
// Initialize financial fields to defaults to satisfy DB constraints
|
||||||
|
order.setSubtotalChf(BigDecimal.ZERO);
|
||||||
|
order.setTotalChf(BigDecimal.ZERO);
|
||||||
|
order.setDiscountChf(BigDecimal.ZERO);
|
||||||
|
order.setSetupCostChf(session.getSetupCostChf()); // Or 0 if null, but session has it
|
||||||
|
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default
|
||||||
|
|
||||||
// Save Order first to get ID
|
// Save Order first to get ID
|
||||||
order = orderRepo.save(order);
|
order = orderRepo.save(order);
|
||||||
|
|
||||||
@@ -162,6 +174,7 @@ public class OrderController {
|
|||||||
oItem.setStoredFilename(storedFilename);
|
oItem.setStoredFilename(storedFilename);
|
||||||
oItem.setStoredRelativePath("PENDING"); // Placeholder
|
oItem.setStoredRelativePath("PENDING"); // Placeholder
|
||||||
oItem.setMimeType("application/octet-stream"); // specific type if known
|
oItem.setMimeType("application/octet-stream"); // specific type if known
|
||||||
|
oItem.setCreatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
|
||||||
@@ -174,11 +187,9 @@ public class OrderController {
|
|||||||
try {
|
try {
|
||||||
Path sourcePath = Paths.get(qItem.getStoredPath());
|
Path sourcePath = Paths.get(qItem.getStoredPath());
|
||||||
if (Files.exists(sourcePath)) {
|
if (Files.exists(sourcePath)) {
|
||||||
Path targetPath = Paths.get(STORAGE_ROOT, relativePath);
|
storageService.store(sourcePath, Paths.get(relativePath));
|
||||||
Files.createDirectories(targetPath.getParent());
|
|
||||||
Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
|
|
||||||
oItem.setFileSizeBytes(Files.size(targetPath));
|
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace(); // Log error but allow order creation? Or fail?
|
e.printStackTrace(); // Log error but allow order creation? Or fail?
|
||||||
@@ -195,6 +206,7 @@ public class OrderController {
|
|||||||
order.setSubtotalChf(subtotal);
|
order.setSubtotalChf(subtotal);
|
||||||
order.setSetupCostChf(session.getSetupCostChf());
|
order.setSetupCostChf(session.getSetupCostChf());
|
||||||
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
|
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
|
||||||
|
order.setDiscountChf(BigDecimal.ZERO);
|
||||||
// TODO: Calc implementation for shipping
|
// TODO: Calc implementation for shipping
|
||||||
|
|
||||||
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||||
@@ -239,14 +251,7 @@ public class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save file to disk
|
// Save file to disk
|
||||||
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
storageService.store(file, Paths.get(relativePath));
|
||||||
Files.createDirectories(absolutePath.getParent());
|
|
||||||
|
|
||||||
if (Files.exists(absolutePath)) {
|
|
||||||
Files.delete(absolutePath); // Overwrite?
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.copy(file.getInputStream(), absolutePath);
|
|
||||||
|
|
||||||
item.setFileSizeBytes(file.getSize());
|
item.setFileSizeBytes(file.getSize());
|
||||||
item.setMimeType(file.getContentType());
|
item.setMimeType(file.getContentType());
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class QuoteSessionController {
|
|||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||||
|
private final com.printcalculator.service.StorageService storageService;
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
@@ -50,13 +51,15 @@ public class QuoteSessionController {
|
|||||||
SlicerService slicerService,
|
SlicerService slicerService,
|
||||||
QuoteCalculator quoteCalculator,
|
QuoteCalculator quoteCalculator,
|
||||||
PrinterMachineRepository machineRepo,
|
PrinterMachineRepository machineRepo,
|
||||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo) {
|
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||||
|
com.printcalculator.service.StorageService storageService) {
|
||||||
this.sessionRepo = sessionRepo;
|
this.sessionRepo = sessionRepo;
|
||||||
this.lineItemRepo = lineItemRepo;
|
this.lineItemRepo = lineItemRepo;
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
this.pricingRepo = pricingRepo;
|
this.pricingRepo = pricingRepo;
|
||||||
|
this.storageService = storageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Start a new empty session
|
// 1. Start a new empty session
|
||||||
@@ -100,9 +103,7 @@ public class QuoteSessionController {
|
|||||||
if (file.isEmpty()) throw new IOException("File is empty");
|
if (file.isEmpty()) throw new IOException("File is empty");
|
||||||
|
|
||||||
// 1. Define Persistent Storage Path
|
// 1. Define Persistent Storage Path
|
||||||
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
|
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
|
||||||
String storageDir = "storage_quotes/" + session.getId();
|
|
||||||
Files.createDirectories(Paths.get(storageDir));
|
|
||||||
|
|
||||||
String originalFilename = file.getOriginalFilename();
|
String originalFilename = file.getOriginalFilename();
|
||||||
String ext = originalFilename != null && originalFilename.contains(".")
|
String ext = originalFilename != null && originalFilename.contains(".")
|
||||||
@@ -110,10 +111,13 @@ public class QuoteSessionController {
|
|||||||
: ".stl";
|
: ".stl";
|
||||||
|
|
||||||
String storedFilename = UUID.randomUUID() + ext;
|
String storedFilename = UUID.randomUUID() + ext;
|
||||||
Path persistentPath = Paths.get(storageDir, storedFilename);
|
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
Files.copy(file.getInputStream(), persistentPath);
|
storageService.store(file, relativePath);
|
||||||
|
|
||||||
|
// Resolve absolute path for slicing and storage usage
|
||||||
|
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Apply Basic/Advanced Logic
|
// Apply Basic/Advanced Logic
|
||||||
@@ -206,7 +210,9 @@ public class QuoteSessionController {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Cleanup if failed
|
// Cleanup if failed
|
||||||
Files.deleteIfExists(persistentPath);
|
try {
|
||||||
|
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
|
||||||
|
} catch (Exception ignored) {}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +336,24 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path path = Paths.get(item.getStoredPath());
|
Path path = Paths.get(item.getStoredPath());
|
||||||
|
// Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
|
||||||
|
// But loadAsResource expects relative path?
|
||||||
|
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
|
||||||
|
// If path is absolute, resolve might fail or behave weirdly.
|
||||||
|
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
|
||||||
|
// If we want to use storageService.loadAsResource, we need the relative path.
|
||||||
|
// Or we just access the file directly if we trust the absolute path.
|
||||||
|
// But we want to use StorageService abstraction.
|
||||||
|
|
||||||
|
// Option 1: Reconstruct relative path.
|
||||||
|
// We know structure: quotes/{sessionId}/{filename}...
|
||||||
|
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
|
||||||
|
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
|
||||||
|
|
||||||
|
// If we trust the file is on disk, we can use UrlResource directly here as before,
|
||||||
|
// relying on the fact that storedPath is the absolute path to the file.
|
||||||
|
// But we should verify it exists.
|
||||||
|
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(path)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
|
public class StorageException extends RuntimeException {
|
||||||
|
|
||||||
|
public StorageException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StorageException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import xyz.capybara.clamav.ClamavClient;
|
||||||
|
import xyz.capybara.clamav.commands.scan.result.ScanResult;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ClamAVService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ClamAVService.class);
|
||||||
|
|
||||||
|
private final ClamavClient clamavClient;
|
||||||
|
|
||||||
|
public ClamAVService(
|
||||||
|
@Value("${clamav.host:localhost}") String host,
|
||||||
|
@Value("${clamav.port:3310}") int port
|
||||||
|
) {
|
||||||
|
logger.info("Initializing ClamAV client at {}:{}", host, port);
|
||||||
|
this.clamavClient = new ClamavClient(host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean scan(InputStream inputStream) {
|
||||||
|
try {
|
||||||
|
ScanResult result = clamavClient.scan(inputStream);
|
||||||
|
if (result instanceof ScanResult.OK) {
|
||||||
|
return true;
|
||||||
|
} else if (result instanceof ScanResult.VirusFound) {
|
||||||
|
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
|
||||||
|
logger.warn("Virus found: {}", viruses);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
logger.warn("Unknown scan result: {}", result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error scanning file with ClamAV", e);
|
||||||
|
// Fail safe? Or fail secure?
|
||||||
|
// Usually if scanner fails, we should probably reject to be safe, or allow with warning depending on policy.
|
||||||
|
// For now, let's reject to be safe.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.UrlResource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import com.printcalculator.exception.StorageException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FileSystemStorageService implements StorageService {
|
||||||
|
|
||||||
|
private final Path rootLocation;
|
||||||
|
private final ClamAVService clamAVService;
|
||||||
|
|
||||||
|
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
|
||||||
|
this.rootLocation = Paths.get(storageLocation);
|
||||||
|
this.clamAVService = clamAVService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(rootLocation);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new StorageException("Could not initialize storage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
|
||||||
|
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||||
|
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||||
|
// Security check
|
||||||
|
throw new StorageException("Cannot store file outside current directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan stream
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
if (!clamAVService.scan(inputStream)) {
|
||||||
|
throw new StorageException("File rejected by antivirus scanner.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset stream? MultipartFile.getInputStream() returns a new stream usually,
|
||||||
|
// but let's verify if we need to open it again.
|
||||||
|
// Yes, we consumed the stream for scanning. We need to open it again for copying.
|
||||||
|
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
Files.createDirectories(destinationFile.getParent());
|
||||||
|
Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void store(Path source, Path destinationRelativePath) throws IOException {
|
||||||
|
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||||
|
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||||
|
throw new StorageException("Cannot store file outside current directory.");
|
||||||
|
}
|
||||||
|
Files.createDirectories(destinationFile.getParent());
|
||||||
|
// We assume source is already safe/scanned if it is internal?
|
||||||
|
// Or should we scan it too?
|
||||||
|
// If it comes from QuoteSession (which was scanned on upload), it is safe.
|
||||||
|
// If we want to be paranoid, we can scan again, but maybe overkill.
|
||||||
|
// Let's assume it is safe for internal copies.
|
||||||
|
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(Path path) throws IOException {
|
||||||
|
Path file = rootLocation.resolve(path);
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Resource loadAsResource(Path path) throws IOException {
|
||||||
|
try {
|
||||||
|
Path file = rootLocation.resolve(path);
|
||||||
|
Resource resource = new UrlResource(file.toUri());
|
||||||
|
if (resource.exists() || resource.isReadable()) {
|
||||||
|
return resource;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Could not read file: " + path);
|
||||||
|
}
|
||||||
|
} catch (MalformedURLException e) {
|
||||||
|
throw new RuntimeException("Could not read file: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface StorageService {
|
||||||
|
void init();
|
||||||
|
void store(MultipartFile file, Path destination) throws IOException;
|
||||||
|
void store(Path source, Path destination) throws IOException;
|
||||||
|
void delete(Path path) throws IOException;
|
||||||
|
Resource loadAsResource(Path path) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.printcalculator.dto.CreateOrderRequest;
|
||||||
|
import com.printcalculator.dto.CustomerDto;
|
||||||
|
import com.printcalculator.dto.AddressDto;
|
||||||
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
|
import com.printcalculator.entity.QuoteSession;
|
||||||
|
import com.printcalculator.repository.OrderRepository;
|
||||||
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.util.FileSystemUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class OrderIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private QuoteSessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private QuoteLineItemRepository lineItemRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OrderRepository orderRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private UUID sessionId;
|
||||||
|
private UUID lineItemId;
|
||||||
|
private final String TEST_FILENAME = "test_model.stl";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() throws Exception {
|
||||||
|
// 1. Create Quote Session
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setMaterialCode("PLA");
|
||||||
|
session.setPricingVersion("v1");
|
||||||
|
session.setCreatedAt(OffsetDateTime.now());
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
|
||||||
|
session.setSetupCostChf(BigDecimal.valueOf(5.00));
|
||||||
|
session.setSupportsEnabled(false);
|
||||||
|
session = sessionRepository.save(session);
|
||||||
|
this.sessionId = session.getId();
|
||||||
|
|
||||||
|
// 2. Create Dummy File on Disk (storage_quotes)
|
||||||
|
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
|
||||||
|
Files.createDirectories(sessionDir);
|
||||||
|
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
|
||||||
|
Files.writeString(filePath, "dummy content");
|
||||||
|
|
||||||
|
// 3. Create Quote Line Item
|
||||||
|
QuoteLineItem item = new QuoteLineItem();
|
||||||
|
item.setQuoteSession(session);
|
||||||
|
item.setStatus("READY");
|
||||||
|
item.setOriginalFilename(TEST_FILENAME);
|
||||||
|
item.setStoredPath(filePath.toString());
|
||||||
|
item.setQuantity(2);
|
||||||
|
item.setPrintTimeSeconds(120);
|
||||||
|
item.setMaterialGrams(BigDecimal.valueOf(10.5));
|
||||||
|
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
|
||||||
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
item = lineItemRepository.save(item);
|
||||||
|
this.lineItemId = item.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() throws Exception {
|
||||||
|
// Cleanup generated files
|
||||||
|
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
|
||||||
|
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
|
||||||
|
|
||||||
|
// Clean DB
|
||||||
|
orderRepository.deleteAll();
|
||||||
|
lineItemRepository.deleteAll();
|
||||||
|
sessionRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
|
||||||
|
// Prepare Request
|
||||||
|
CreateOrderRequest request = new CreateOrderRequest();
|
||||||
|
|
||||||
|
CustomerDto customer = new CustomerDto();
|
||||||
|
customer.setEmail("integration@test.com");
|
||||||
|
customer.setCustomerType("PRIVATE");
|
||||||
|
request.setCustomer(customer);
|
||||||
|
|
||||||
|
AddressDto billing = new AddressDto();
|
||||||
|
billing.setFirstName("John");
|
||||||
|
billing.setLastName("Doe");
|
||||||
|
billing.setAddressLine1("Street 1");
|
||||||
|
billing.setCity("City");
|
||||||
|
billing.setZip("1000");
|
||||||
|
billing.setCountryCode("CH");
|
||||||
|
request.setBillingAddress(billing);
|
||||||
|
|
||||||
|
request.setShippingSameAsBilling(true);
|
||||||
|
|
||||||
|
// Execute Request
|
||||||
|
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
// Verify Session Status
|
||||||
|
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
|
||||||
|
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
|
||||||
|
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
|
||||||
|
|
||||||
|
UUID orderId = updatedSession.getConvertedOrderId();
|
||||||
|
|
||||||
|
// Verify File Copy
|
||||||
|
Path orderStorageDir = Paths.get("storage_orders");
|
||||||
|
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
|
||||||
|
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
|
||||||
|
|
||||||
|
try (var stream = Files.walk(orderStorageDir)) {
|
||||||
|
boolean fileFound = stream
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.anyMatch(path -> {
|
||||||
|
try {
|
||||||
|
return Files.readString(path).equals("dummy content");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,19 @@ services:
|
|||||||
- MARKUP_PERCENT=20
|
- MARKUP_PERCENT=20
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
|
- CLAMAV_HOST=clamav
|
||||||
|
- CLAMAV_PORT=3310
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
- clamav
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
clamav:
|
||||||
|
platform: linux/amd64
|
||||||
|
image: clamav/clamav:latest
|
||||||
|
container_name: print-calculator-clamav
|
||||||
|
ports:
|
||||||
|
- "3310:3310"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
Reference in New Issue
Block a user