diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/DirectoryConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/DirectoryConstants.java index 697212cd..df52f70e 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/DirectoryConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/DirectoryConstants.java @@ -8,5 +8,4 @@ public class DirectoryConstants { public static final String DATA_DIR = "data"; public static final String WORK_DIR = "work"; public static final String CACHE_DIR = "market-cache"; - public static final String PREVIEW_DIR = "marketplace-service/release-preview"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/PreviewConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/PreviewConstants.java new file mode 100644 index 00000000..d9fce76a --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/PreviewConstants.java @@ -0,0 +1,13 @@ +package com.axonivy.market.constants; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PreviewConstants { + + public static final String PREVIEW_DIR = "marketplace-service/release-preview"; + + public static final String IMAGE_DOWNLOAD_URL = "%s/api/image/preview/%s"; + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java index 638cbaf2..247cd676 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.Optional; -import static com.axonivy.market.constants.DirectoryConstants.PREVIEW_DIR; +import static com.axonivy.market.constants.PreviewConstants.PREVIEW_DIR; @Service @Log4j2 @@ -121,7 +121,6 @@ public byte[] readPreviewImageByName(String imageName) { log.info("#readPreviewImageByName: Preview folder not found"); } try { - Optional imagePath = Files.walk(previewPath) .filter(Files::isRegularFile) .filter(path -> path.getFileName().toString().equalsIgnoreCase(imageName)) diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ReleasePreviewServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ReleasePreviewServiceImpl.java index 48a1c096..9befedfe 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ReleasePreviewServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ReleasePreviewServiceImpl.java @@ -5,74 +5,45 @@ import com.axonivy.market.model.ReadmeContentsModel; import com.axonivy.market.model.ReleasePreview; import com.axonivy.market.service.ReleasePreviewService; +import com.axonivy.market.util.FileUtils; import com.axonivy.market.util.ProductContentUtils; import lombok.AllArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Stream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import static com.axonivy.market.constants.DirectoryConstants.PREVIEW_DIR; +import static com.axonivy.market.constants.PreviewConstants.IMAGE_DOWNLOAD_URL; +import static com.axonivy.market.constants.PreviewConstants.PREVIEW_DIR; @Log4j2 @Service @AllArgsConstructor public class ReleasePreviewServiceImpl implements ReleasePreviewService { - private final static String IMAGE_DOWNLOAD_URL = "%s/api/image/preview/%s"; @Override public ReleasePreview extract(MultipartFile file, String baseUrl) { - unzip(file); - return extractREADME(baseUrl); - } - - private void unzip(MultipartFile file) { try { - File extractDir = new File(PREVIEW_DIR); - prepareUnZipDirectory(extractDir.toPath()); - - try (ZipInputStream zipInputStream = new ZipInputStream(file.getInputStream())) { - ZipEntry entry; - while ((entry = zipInputStream.getNextEntry()) != null) { - File outFile = new File(extractDir, entry.getName()); - if (entry.isDirectory()) { - outFile.mkdirs(); - } else { - new File(outFile.getParent()).mkdirs(); - try (FileOutputStream fos = new FileOutputStream(outFile)) { - byte[] buffer = new byte[1024]; - int length; - while ((length = zipInputStream.read(buffer)) > 0) { - fos.write(buffer, 0, length); - } - } - } - zipInputStream.closeEntry(); - } - } - } catch (IOException e) { - log.error("#unzip An exception occurred when unzip file {} - message {}", file.getName(), e.getMessage()); + FileUtils.unzip(file, PREVIEW_DIR); + } catch (IOException e){ + log.info("#extract Error extracting zip file, message: {}", e.getMessage()); } + return extractREADME(baseUrl, PREVIEW_DIR); } - private ReleasePreview extractREADME(String baseUrl) { + public ReleasePreview extractREADME(String baseUrl, String location) { Map> moduleContents = new HashMap<>(); - try (Stream readmePathStream = Files.walk(Paths.get(PREVIEW_DIR))) { + try (Stream readmePathStream = Files.walk(Paths.get(location))) { List readmeFiles = readmePathStream.filter(Files::isRegularFile) .filter(path -> path.getFileName().toString().startsWith(ReadmeConstants.README_FILE_NAME)) .toList(); @@ -80,12 +51,12 @@ private ReleasePreview extractREADME(String baseUrl) { return null; } for (Path readmeFile : readmeFiles) { - processReadme(readmeFile, moduleContents, baseUrl); + processReadme(readmeFile, moduleContents, baseUrl, location); } return ReleasePreview.from(moduleContents); } catch (IOException e) { log.error("Cannot get README file's content from folder {}: {}", - com.axonivy.market.constants.DirectoryConstants.PREVIEW_DIR, e.getMessage()); + PREVIEW_DIR, e.getMessage()); return null; } } @@ -111,25 +82,11 @@ public String updateImagesWithDownloadUrl(String unzippedFolderPath, } } - private static void prepareUnZipDirectory(Path directory) { - try { - if (Files.exists(directory)) { - Files.walk(directory) - .sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); - } - Files.createDirectories(directory); - } catch (IOException e) { - log.error("#prepareDirectory: Error managing directory {} : {}", directory.toString(), e.getMessage()); - } - } - - private void processReadme(Path readmeFile, Map> moduleContents, - String baseUrl) throws IOException { + public void processReadme(Path readmeFile, Map> moduleContents, + String baseUrl, String location) throws IOException { String readmeContents = Files.readString(readmeFile); if (ProductContentUtils.hasImageDirectives(readmeContents)) { - readmeContents = updateImagesWithDownloadUrl(PREVIEW_DIR, readmeContents, baseUrl); + readmeContents = updateImagesWithDownloadUrl(location, readmeContents, baseUrl); } ReadmeContentsModel readmeContentsModel = ProductContentUtils.getExtractedPartsOfReadme(readmeContents); ProductContentUtils.mappingDescriptionSetupAndDemo( @@ -138,4 +95,5 @@ private void processReadme(Path readmeFile, Map> mod readmeContentsModel ); } + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java index 6eeb3524..a60d119c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/util/FileUtils.java @@ -2,10 +2,18 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; import java.io.File; +import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class FileUtils { @@ -28,4 +36,60 @@ public static void writeToFile(File file, String content) throws IOException { } } + public static void unzip(MultipartFile file, String location) throws IOException { + File extractDir = new File(location); + prepareUnZipDirectory(extractDir.toPath()); + + try (ZipInputStream zipInputStream = new ZipInputStream(file.getInputStream())) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + Path entryPath = Paths.get(entry.getName()).normalize(); + Path resolvedPath = extractDir.toPath().resolve(entryPath).normalize(); + + // Ensure the resolved path is within the target directory + if (!resolvedPath.startsWith(extractDir.toPath())) { + throw new IOException("Entry is outside the target dir: " + entry.getName()); + } + + File outFile = resolvedPath.toFile(); + if (entry.isDirectory()) { + if (!outFile.mkdirs() && !outFile.isDirectory()) { + throw new IOException("Failed to create directory: " + outFile); + } + } else { + File parentDir = outFile.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + throw new IOException("Failed to create parent directory: " + parentDir); + } + + try (FileOutputStream fos = new FileOutputStream(outFile)) { + byte[] buffer = new byte[1024]; + int length; + while ((length = zipInputStream.read(buffer)) > 0) { + fos.write(buffer, 0, length); + } + } + } + zipInputStream.closeEntry(); + } + } catch (IOException e) { + throw new IOException("Error unzipping file", e); + } + } + + + public static void prepareUnZipDirectory(Path directory) throws IOException { + clearDirectory(directory); + Files.createDirectories(directory); + } + + public static void clearDirectory(Path path) throws IOException { + if (Files.exists(path)) { + Files.walk(path) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(java.io.File::delete); + } + } + } diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ReleasePreviewServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ReleasePreviewServiceImplTest.java new file mode 100644 index 00000000..480f3d0a --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ReleasePreviewServiceImplTest.java @@ -0,0 +1,140 @@ +package com.axonivy.market.service.impl; + +import com.axonivy.market.model.ReleasePreview; +import com.axonivy.market.util.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import static com.axonivy.market.constants.PreviewConstants.IMAGE_DOWNLOAD_URL; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +; + +@ExtendWith(MockitoExtension.class) +class ReleasePreviewServiceImplTest { + + private ReleasePreviewServiceImpl releasePreviewService; + + private Path tempDirectory; + + private final String baseUrl = "http://example.com"; + + private final String readmeContent = "# Sample README Content\n![image](image1.png)"; + + private final String updatedReadme = "# Sample README Content\n![image](http://example" + + ".com/api/image/preview/image1.png)"; + + @BeforeEach + void setUp() throws IOException { + releasePreviewService = spy(new ReleasePreviewServiceImpl()); + tempDirectory = Files.createTempDirectory("test-dir"); + } + + @AfterEach + void tearDown() throws IOException { + FileUtils.clearDirectory(tempDirectory); + } + + @Test + void testProcessReadme() throws IOException { + Path tempReadmeFile = Files.createTempFile("README", ".md"); + Files.writeString(tempReadmeFile, readmeContent); + Map> moduleContents = new HashMap<>(); + doReturn(updatedReadme).when(releasePreviewService) + .updateImagesWithDownloadUrl(tempDirectory.toString(), readmeContent, baseUrl); + releasePreviewService.processReadme(tempReadmeFile, moduleContents, baseUrl, tempDirectory.toString()); + assertEquals(3, moduleContents.size()); + Files.deleteIfExists(tempReadmeFile); + } + + @Test + void testUpdateImagesWithDownloadUrl_Success() throws IOException { + Path tempReadmeFile = Files.createTempFile("README", ".md"); + Files.writeString(tempReadmeFile, readmeContent); + String parentPath = tempReadmeFile.getParent().toString(); + + Path imagePath1 = Paths.get(parentPath + "/image1.png"); + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.walk(Paths.get(parentPath))) + .thenReturn(Stream.of(imagePath1)); + mockedFiles.when(() -> Files.isRegularFile(any())) + .thenReturn(true); + String result = releasePreviewService.updateImagesWithDownloadUrl(parentPath, + readmeContent + , baseUrl); + + assertNotNull(result); + assertTrue(result.contains(String.format(IMAGE_DOWNLOAD_URL, baseUrl, "image1.png"))); + } + } + + @Test + void testUpdateImagesWithDownloadUrl_IOException() { + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.walk(tempDirectory)) + .thenThrow(new IOException("Simulated IOException")); + String result = releasePreviewService.updateImagesWithDownloadUrl(tempDirectory.toString(), readmeContent, + baseUrl); + assertNull(result); + assertDoesNotThrow( + () -> releasePreviewService.updateImagesWithDownloadUrl(tempDirectory.toString(), readmeContent, baseUrl)); + } + } + + @Test + void testExtractREADME_Success() throws IOException { + String parentPath = tempDirectory.getParent().toString(); + Path readmeFile1 = FileUtils.createFile(parentPath + "/README.md").toPath(); + Files.writeString(readmeFile1, readmeContent); + + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.walk(tempDirectory)) + .thenReturn(Stream.of(readmeFile1)); + mockedFiles.when(() -> Files.isRegularFile(any())) + .thenReturn(true); + mockedFiles.when(() -> Files.readString(any())) + .thenReturn(readmeContent); + when(releasePreviewService.updateImagesWithDownloadUrl(any(), anyString(), anyString())).thenReturn( + updatedReadme); + ReleasePreview result = releasePreviewService.extractREADME(baseUrl, tempDirectory.toString()); + assertNotNull(result); + } + } + + @Test + void testExtractREADME_NoReadmeFiles() { + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.walk(tempDirectory)) + .thenReturn(Stream.empty()); + ReleasePreview result = releasePreviewService.extractREADME(baseUrl, tempDirectory.toString()); + assertNull(result); + mockedFiles.verify(() -> Files.walk(tempDirectory), times(1)); + } + } + + @Test + void testExtractREADME_IOException() { + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.walk(tempDirectory)) + .thenThrow(new IOException("Simulated IOException")); + ReleasePreview result = releasePreviewService.extractREADME(baseUrl, tempDirectory.toString()); + assertNull(result); + assertDoesNotThrow( + () -> releasePreviewService.extractREADME(baseUrl, tempDirectory.toString())); + } + } + +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java index 196152d8..f887c7d3 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/util/FileUtilsTest.java @@ -2,13 +2,21 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class FileUtilsTest { @@ -54,4 +62,49 @@ void testWriteFile() throws IOException { } + @Test + void testPrepareUnZipDirectory() throws IOException { + Path tempDirectory = Files.createTempDirectory("test-dir"); + FileUtils.prepareUnZipDirectory(tempDirectory); + assertTrue(Files.exists(tempDirectory)); + FileUtils.clearDirectory(tempDirectory); + } + + @Test + void testPrepareUnZipDirectory_IOException() throws IOException { + Path tempDirectory = Files.createTempDirectory("test-dir"); + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.exists(tempDirectory)).thenReturn(true); + } + assertDoesNotThrow(() -> FileUtils.prepareUnZipDirectory(tempDirectory)); + FileUtils.clearDirectory(tempDirectory); + } + + @Test + void testUnzip_Success() throws Exception { + Path tempDirectory = Files.createTempDirectory("test-dir"); + String mockFileName = "test.zip"; + byte[] zipContent = createMockZipContent(); + MockMultipartFile mockMultipartFile = new MockMultipartFile("file", mockFileName, "application/zip", zipContent); + try (MockedStatic mockedFiles = mockStatic(Files.class)) { + mockedFiles.when(() -> Files.exists(tempDirectory)).thenReturn(false); + mockedFiles.when(() -> Files.createDirectories(tempDirectory)).thenReturn(null); + FileUtils.unzip(mockMultipartFile, String.valueOf(tempDirectory)); + mockedFiles.verify(() -> Files.exists(tempDirectory), times(1)); + mockedFiles.verify(() -> Files.createDirectories(tempDirectory), times(1)); + } + FileUtils.clearDirectory(tempDirectory); + } + + private byte[] createMockZipContent() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + ZipEntry entry = new ZipEntry("mockFile.txt"); + zos.putNextEntry(entry); + zos.write("Mock file content".getBytes()); + zos.closeEntry(); + } + return baos.toByteArray(); + } + }