Zip multiple csvs

This commit is contained in:
Maksim Tereshin 2025-10-02 12:32:32 +05:00
parent 4b709e1a3d
commit 2a1d34ac10
No known key found for this signature in database
9 changed files with 7158 additions and 2888 deletions

2
.gitignore vendored
View file

@ -65,3 +65,5 @@ npm-debug.log
#Sublime project files
*.sublime-project
*.sublime-workspace
CLAUDE.md

View file

@ -20,6 +20,36 @@
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.6.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.6.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.23.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
@ -28,6 +58,31 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
@ -88,7 +143,7 @@
</repository>
</repositories>
<build>
<finalName>${artifactId}</finalName>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@ -113,6 +168,17 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<includes>
<include>**/*Test.java</include>
<include>**/*Tests.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -99,17 +99,31 @@ public class ApiController {
public ResponseEntity<Resource> downloadCSV(@RequestBody RequestParameters request) throws IOException, SQLException {
logger.debug("Starting downloadCSV process for request: {}", request.getType());
File csvFile = apiService.download(ConfigType.DOWNLOAD_CSV, request);
InputStreamResource resource = new InputStreamResource(new FileInputStream(csvFile));
File downloadFile = apiService.download(ConfigType.DOWNLOAD_CSV, request);
InputStreamResource resource = new InputStreamResource(new FileInputStream(downloadFile));
logger.debug("Finished downloadCSV process for request: {}. Sending to user...", request.getType());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + csvFile.getName())
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.header(HttpHeaders.CONTENT_ENCODING, "UTF-8")
.contentLength(csvFile.length())
.body(resource);
// Determine content type based on file extension
String fileName = downloadFile.getName();
MediaType contentType;
if (fileName.toLowerCase().endsWith(".zip")) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
} else {
contentType = MediaType.parseMediaType("text/csv; charset=UTF-8");
}
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileName)
.contentType(contentType)
.contentLength(downloadFile.length());
// Only add content encoding for CSV files
if (!fileName.endsWith(".zip")) {
response.header(HttpHeaders.CONTENT_ENCODING, "UTF-8");
}
return response.body(resource);
}

View file

@ -25,11 +25,14 @@ import java.sql.SQLException;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Service
public class DownloadService {
private static final Logger logger = LoggerFactory.getLogger(DownloadService.class);
private static final int MAX_ROWS_PER_CSV = 600000;
public File download(BaseDownloadRequest selectedRequest, List<String> ids, RequestParameters parameters, Map<String, Boolean> validationResults) throws SQLException {
LocalDate startDate = parameters.getStartDate();
@ -113,23 +116,16 @@ public class DownloadService {
List<String[]> results = executeSqlQuery(connection, requestURL);
File csvFile = File.createTempFile("download-", ".csv");
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(new FileOutputStream(csvFile), StandardCharsets.UTF_8))) {
String lineSeparator = "\r\n";
for (String[] row : results) {
writer.print(formatCsvRow(row));
writer.print(lineSeparator);
}
} catch (IOException e) {
logger.error("Failed to write to CSV file", e);
// Check if we need to split into multiple files
// Note: results includes header row, so we check size - 1 for actual data rows
if (results.size() - 1 <= MAX_ROWS_PER_CSV) {
// Single CSV file
return writeSingleSqlCsvFile(results, "download-", ".csv");
} else {
// Multiple CSV files in ZIP
return writeSqlResultsToZip(results);
}
return csvFile;
}
catch (SQLException | IOException e) {
logger.error("SQL execution failed for query: {}", query, e);
@ -137,6 +133,55 @@ public class DownloadService {
return null;
}
private File writeSingleSqlCsvFile(List<String[]> results, String prefix, String suffix) throws IOException {
File csvFile = File.createTempFile(prefix, suffix);
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(new FileOutputStream(csvFile), StandardCharsets.UTF_8))) {
String lineSeparator = "\r\n";
for (String[] row : results) {
writer.print(formatCsvRow(row));
writer.print(lineSeparator);
}
}
return csvFile;
}
private File writeSqlResultsToZip(List<String[]> results) throws IOException {
List<File> csvFiles = new ArrayList<>();
// Extract header
String[] headers = results.isEmpty() ? new String[0] : results.get(0);
int fileIndex = 1;
int currentRowIndex = 1; // Start from 1 to skip header in original results
while (currentRowIndex < results.size()) {
List<String[]> chunk = new ArrayList<>();
// Always add headers as first row
chunk.add(headers);
// Add data rows up to MAX_ROWS_PER_CSV (including the header)
int chunkEndIndex = Math.min(currentRowIndex + MAX_ROWS_PER_CSV - 1, results.size());
for (int i = currentRowIndex; i < chunkEndIndex; i++) {
chunk.add(results.get(i));
}
File csvFile = writeSingleSqlCsvFile(chunk, "download-part" + fileIndex + "-", ".csv");
if (csvFile != null) {
csvFiles.add(csvFile);
fileIndex++;
}
currentRowIndex = chunkEndIndex;
}
// Create ZIP archive
return createZipArchive(csvFiles, "download-");
}
private List<Map<String, Object>> executeSelectAqlRequest(ArangoDatabase arangoDb,
String downloadRequestEntitySelectorQuery,
List<String> ids, LocalDate startDate, LocalDate endDate, Boolean emptyIdsAllowed, Boolean emptyDatesAllowed) {
@ -272,33 +317,88 @@ public class DownloadService {
}
private File writeResultsToCsv(List<Map<String, Object>> results) {
File csvFile;
try {
csvFile = File.createTempFile("arango-download-", ".csv");
// If results fit in a single file, create one CSV
if (results.size() <= MAX_ROWS_PER_CSV) {
return writeSingleCsvFile(results, "arango-download-", ".csv");
}
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(csvFile), StandardCharsets.UTF_8))) {
// Otherwise, create multiple CSV files and zip them
List<File> csvFiles = new ArrayList<>();
int fileIndex = 1;
if (!results.isEmpty()) {
List<String> headers = new ArrayList<>(results.get(0).keySet());
writer.write(String.join(",", headers));
writer.newLine();
for (int i = 0; i < results.size(); i += MAX_ROWS_PER_CSV) {
int endIndex = Math.min(i + MAX_ROWS_PER_CSV, results.size());
List<Map<String, Object>> chunk = results.subList(i, endIndex);
for (Map<String, Object> row : results) {
List<String> rowValues = headers.stream()
.map(header -> formatCsvField(row.get(header)))
.collect(Collectors.toList());
writer.write(String.join(",", rowValues));
writer.newLine();
}
File csvFile = writeSingleCsvFile(chunk, "arango-download-part" + fileIndex + "-", ".csv");
if (csvFile != null) {
csvFiles.add(csvFile);
fileIndex++;
}
}
// Create ZIP archive
return createZipArchive(csvFiles, "arango-download-");
} catch (IOException e) {
logger.error("Failed to write results to CSV", e);
return null;
}
}
private File writeSingleCsvFile(List<Map<String, Object>> results, String prefix, String suffix) throws IOException {
File csvFile = File.createTempFile(prefix, suffix);
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(csvFile), StandardCharsets.UTF_8))) {
if (!results.isEmpty()) {
List<String> headers = new ArrayList<>(results.get(0).keySet());
writer.write(String.join(",", headers));
writer.newLine();
for (Map<String, Object> row : results) {
List<String> rowValues = headers.stream()
.map(header -> formatCsvField(row.get(header)))
.collect(Collectors.toList());
writer.write(String.join(",", rowValues));
writer.newLine();
}
}
}
return csvFile;
}
private File createZipArchive(List<File> files, String prefix) throws IOException {
File zipFile = File.createTempFile(prefix, ".zip");
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
for (File file : files) {
addFileToZip(file, zos);
// Delete temporary CSV files after adding to ZIP
if (!file.delete()) {
logger.warn("Failed to delete temporary file: {}", file.getName());
}
}
}
return zipFile;
}
private void addFileToZip(File file, ZipOutputStream zos) throws IOException {
try (FileInputStream fis = new FileInputStream(file)) {
ZipEntry zipEntry = new ZipEntry(file.getName());
zos.putNextEntry(zipEntry);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) >= 0) {
zos.write(buffer, 0, length);
}
zos.closeEntry();
}
}
private String formatCsvField(Object value) {
if (value == null) {
return "\"\"";

View file

@ -0,0 +1,283 @@
package org.micord.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.micord.enums.ConfigType;
import org.micord.models.requests.RequestParameters;
import org.micord.service.ApiService;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ApiControllerTest {
@Mock
private ApiService apiService;
@InjectMocks
private ApiController apiController;
@TempDir
Path tempDir;
private RequestParameters testRequest;
@BeforeEach
void setUp() {
testRequest = new RequestParameters();
testRequest.setType("test-download-type");
testRequest.setIds(Arrays.asList("id1", "id2", "id3"));
testRequest.setStartDate(LocalDate.of(2024, 1, 1));
testRequest.setEndDate(LocalDate.of(2024, 12, 31));
}
@Test
void testDownloadCSV_SingleCsvFile() throws Exception {
// Given: Service returns a single CSV file
File csvFile = createTempCsvFile("test-download.csv");
when(apiService.download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class)))
.thenReturn(csvFile);
// When: Calling downloadCSV endpoint
ResponseEntity<Resource> response = apiController.downloadCSV(testRequest);
// Then: Response should have CSV content type and headers
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.parseMediaType("text/csv; charset=UTF-8"));
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.startsWith("attachment; filename=")
.endsWith(".csv");
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING))
.isEqualTo("UTF-8");
assertThat(response.getHeaders().getContentLength()).isEqualTo(csvFile.length());
verify(apiService).download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class));
}
@Test
void testDownloadCSV_ZipFile() throws Exception {
// Given: Service returns a ZIP file (for large datasets)
File zipFile = createTempZipFile("test-download.zip");
when(apiService.download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class)))
.thenReturn(zipFile);
// When: Calling downloadCSV endpoint
ResponseEntity<Resource> response = apiController.downloadCSV(testRequest);
// Then: Response should have ZIP content type and headers
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getHeaders().getContentType())
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.startsWith("attachment; filename=")
.endsWith(".zip");
// Content-Encoding should NOT be set for ZIP files
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING))
.isNull();
assertThat(response.getHeaders().getContentLength()).isEqualTo(zipFile.length());
verify(apiService).download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class));
}
@Test
void testDownloadCSV_HandlesIOException() throws Exception {
// Given: Service throws IOException
when(apiService.download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class)))
.thenThrow(new IOException("Test IO error"));
// When/Then: Should propagate the exception
assertThrows(IOException.class, () -> {
apiController.downloadCSV(testRequest);
});
}
@Test
void testDownloadCSV_EmptyRequest() throws Exception {
// Given: Empty request parameters
RequestParameters emptyRequest = new RequestParameters();
emptyRequest.setType("empty-type");
File csvFile = createTempCsvFile("empty.csv");
when(apiService.download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class)))
.thenReturn(csvFile);
// When: Calling with empty request
ResponseEntity<Resource> response = apiController.downloadCSV(emptyRequest);
// Then: Should still process successfully
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isNotNull();
}
@Test
void testListDownloadTypes() throws Exception {
// Given: Service returns list of download types
List<String> downloadTypes = Arrays.asList(
"TYPE_1", "TYPE_2", "TYPE_3", "TYPE_4"
);
when(apiService.getDownloadTypes(ConfigType.DOWNLOAD_CSV))
.thenReturn(downloadTypes);
// When: Calling listDownloadTypes endpoint
ResponseEntity<List<String>> response = apiController.listDownloadTypes();
// Then: Should return the list
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo(downloadTypes);
assertThat(response.getBody()).hasSize(4);
verify(apiService).getDownloadTypes(ConfigType.DOWNLOAD_CSV);
}
@Test
void testRemoveMilitaryDraftNotices() throws Exception {
// Given: Request with IDs
RequestParameters request = new RequestParameters();
request.setIds(Arrays.asList("notice1", "notice2"));
doNothing().when(apiService).process(eq(ConfigType.REMOVE_MILITARY_DRAFT_NOTICES), any(RequestParameters.class));
// When: Calling removeMilitaryDraftNotices
ResponseEntity<?> response = apiController.removeMilitaryDraftNotices(request);
// Then: Should return success message
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("Операция \"Удаление повесток\" завершена успешно.");
verify(apiService).process(eq(ConfigType.REMOVE_MILITARY_DRAFT_NOTICES), any(RequestParameters.class));
}
@Test
void testDeleteFiles() throws Exception {
// Given: List of file IDs
List<String> fileIds = Arrays.asList("file1", "file2", "file3");
doNothing().when(apiService).process(eq(ConfigType.DELETE_FILES), eq(fileIds));
// When: Calling deleteFiles
ResponseEntity<?> response = apiController.deleteFiles(fileIds);
// Then: Should return success message
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("Операция \"Удаление файлов\" завершена успешно.");
verify(apiService).process(ConfigType.DELETE_FILES, fileIds);
}
@Test
void testBlock() throws Exception {
// Given: List of IDs to block
List<String> ids = Arrays.asList("user1", "user2");
doNothing().when(apiService).process(eq(ConfigType.BLOCK), eq(ids));
// When: Calling block
ResponseEntity<?> response = apiController.block(ids);
// Then: Should return success message
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("Операция \"Блокировка\" завершена успешно.");
verify(apiService).process(ConfigType.BLOCK, ids);
}
@Test
void testUnblock() throws Exception {
// Given: List of IDs to unblock
List<String> ids = Arrays.asList("user1", "user2");
doNothing().when(apiService).process(eq(ConfigType.UNBLOCK), eq(ids));
// When: Calling unblock
ResponseEntity<?> response = apiController.unblock(ids);
// Then: Should return success message
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("Операция \"Разблокировка\" завершена успешно.");
verify(apiService).process(ConfigType.UNBLOCK, ids);
}
@Test
void testContentTypeDetection() throws Exception {
// Test various file extensions
testFileContentType("data.csv", MediaType.parseMediaType("text/csv; charset=UTF-8"));
testFileContentType("archive.zip", MediaType.APPLICATION_OCTET_STREAM);
testFileContentType("report.CSV", MediaType.parseMediaType("text/csv; charset=UTF-8"));
testFileContentType("bundle.ZIP", MediaType.APPLICATION_OCTET_STREAM);
}
private void testFileContentType(String filename, MediaType expectedType) throws Exception {
// Create file with specific extension
File file = tempDir.resolve(filename).toFile();
file.createNewFile();
when(apiService.download(eq(ConfigType.DOWNLOAD_CSV), any(RequestParameters.class)))
.thenReturn(file);
ResponseEntity<Resource> response = apiController.downloadCSV(testRequest);
assertThat(response.getHeaders().getContentType()).isEqualTo(expectedType);
}
// Helper methods
private File createTempCsvFile(String filename) throws IOException {
File csvFile = Files.createTempFile(tempDir, filename.replace(".csv", ""), ".csv").toFile();
try (FileOutputStream fos = new FileOutputStream(csvFile)) {
String csvContent = "header1,header2,header3\n" +
"value1,value2,value3\n" +
"value4,value5,value6\n";
fos.write(csvContent.getBytes(StandardCharsets.UTF_8));
}
return csvFile;
}
private File createTempZipFile(String filename) throws IOException {
File zipFile = Files.createTempFile(tempDir, filename.replace(".zip", ""), ".zip").toFile();
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
// Add first CSV
ZipEntry entry1 = new ZipEntry("part1.csv");
zos.putNextEntry(entry1);
zos.write("header1,header2\ndata1,data2\n".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
// Add second CSV
ZipEntry entry2 = new ZipEntry("part2.csv");
zos.putNextEntry(entry2);
zos.write("header1,header2\ndata3,data4\n".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
return zipFile;
}
}

View file

@ -0,0 +1,290 @@
package org.micord.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class DownloadServiceTest {
@InjectMocks
private DownloadService downloadService;
@TempDir
Path tempDir;
private static final int MAX_ROWS_PER_CSV = 600000;
@BeforeEach
void setUp() {
downloadService = new DownloadService();
}
@Test
void testSingleCsvFileWhenRowsUnderLimit() throws Exception {
// Given: Dataset with less than 600k rows
List<Map<String, Object>> testData = generateTestData(100000);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Should create single CSV file, not ZIP
assertThat(result).isNotNull();
assertThat(result.getName()).endsWith(".csv");
assertThat(result.getName()).doesNotEndWith(".zip");
// Verify CSV content
List<String> lines = Files.readAllLines(result.toPath());
// Header + 100k data rows
assertThat(lines).hasSize(100001);
}
@Test
void testMultipleCsvFilesWhenRowsOverLimit() throws Exception {
// Given: Dataset with more than 600k rows (1.5 million)
List<Map<String, Object>> testData = generateTestData(1500000);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Should create ZIP file
assertThat(result).isNotNull();
assertThat(result.getName()).endsWith(".zip");
// Verify ZIP contains 3 CSV files (600k + 600k + 300k)
try (ZipFile zipFile = new ZipFile(result)) {
assertThat(Collections.list(zipFile.entries())).hasSize(3);
zipFile.entries().asIterator().forEachRemaining(entry -> {
assertThat(entry.getName()).endsWith(".csv");
assertThat(entry.getName()).contains("part");
});
}
}
@Test
void testExactlyMaxRowsPerCsvFile() throws Exception {
// Given: Exactly 600k rows
List<Map<String, Object>> testData = generateTestData(MAX_ROWS_PER_CSV);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Should create single CSV file
assertThat(result).isNotNull();
assertThat(result.getName()).endsWith(".csv");
List<String> lines = Files.readAllLines(result.toPath());
assertThat(lines).hasSize(MAX_ROWS_PER_CSV + 1); // +1 for header
}
@Test
void testChunkingPreservesHeaders() throws Exception {
// Given: Dataset that requires chunking
List<Map<String, Object>> testData = generateTestData(1200000);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Each CSV in ZIP should have headers
try (ZipFile zipFile = new ZipFile(result)) {
zipFile.entries().asIterator().forEachRemaining(entry -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zipFile.getInputStream(entry)))) {
String firstLine = reader.readLine();
// Verify header exists
assertThat(firstLine).contains("column1,column2,column3");
} catch (IOException e) {
fail("Failed to read ZIP entry: " + e.getMessage());
}
});
}
}
@Test
void testEmptyResultsHandling() throws Exception {
// Given: Empty dataset
List<Map<String, Object>> testData = new ArrayList<>();
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Should create empty CSV file
assertThat(result).isNotNull();
assertThat(result.getName()).endsWith(".csv");
List<String> lines = Files.readAllLines(result.toPath());
assertThat(lines).isEmpty();
}
@Test
void testLargeDatasetChunking() throws Exception {
// Given: 2 million rows (should create 4 files)
List<Map<String, Object>> testData = generateTestData(2000000);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Should create ZIP with 4 CSV files
assertThat(result.getName()).endsWith(".zip");
try (ZipFile zipFile = new ZipFile(result)) {
List<? extends ZipEntry> entries = Collections.list(zipFile.entries());
assertThat(entries).hasSize(4);
// Verify file names are sequential
assertThat(entries.get(0).getName()).contains("part1");
assertThat(entries.get(1).getName()).contains("part2");
assertThat(entries.get(2).getName()).contains("part3");
assertThat(entries.get(3).getName()).contains("part4");
}
}
@Test
void testCsvFieldFormatting() throws Exception {
// Given: Data with special characters that need escaping
List<Map<String, Object>> testData = new ArrayList<>();
// Use LinkedHashMap to preserve field order
Map<String, Object> row = new LinkedHashMap<>();
row.put("normal", "value");
row.put("withQuote", "value\"with\"quotes");
row.put("withComma", "value,with,commas");
row.put("withNewline", "value\nwith\nnewlines");
row.put("nullValue", null);
testData.add(row);
// When: Writing results to CSV
File result = invokeWriteResultsToCsv(testData);
// Then: Fields should be properly escaped
String content = Files.readString(result.toPath());
// Verify proper escaping
assertThat(content).contains("\"value\"\"with\"\"quotes\"");
assertThat(content).contains("\"value,with,commas\"");
assertThat(content).contains("\"\""); // null value
}
@Test
void testSqlResultsToSingleFile() throws Exception {
// Given: SQL results under the limit
List<String[]> sqlResults = generateSqlTestData(50000);
// When: Writing SQL results to CSV
File result = invokeWriteSingleSqlCsvFile(sqlResults, "test-", ".csv");
// Then: Should create single CSV file
assertThat(result).isNotNull();
assertThat(result.getName()).startsWith("test-");
assertThat(result.getName()).endsWith(".csv");
List<String> lines = Files.readAllLines(result.toPath());
assertThat(lines).hasSize(50000);
}
@Test
void testSqlResultsChunking() throws Exception {
// Given: SQL results that will create exactly 2 chunks
// generateSqlTestData(1199999) creates 1 header + 1,199,998 data rows = 1,199,999 total
// With MAX_ROWS_PER_CSV = 600,000:
// File 1: header + 599,999 data rows = 600,000 total
// File 2: header + 599,999 data rows = 600,000 total
List<String[]> sqlResults = generateSqlTestData(1199999);
// When: Writing SQL results with chunking
File result = invokeWriteSqlResultsToZip(sqlResults);
// Then: Should create ZIP with 2 CSV files
assertThat(result).isNotNull();
assertThat(result.getName()).endsWith(".zip");
try (ZipFile zipFile = new ZipFile(result)) {
assertThat(Collections.list(zipFile.entries())).hasSize(2);
}
}
@Test
void testZipFileContentsIntegrity() throws Exception {
// Given: Large dataset
List<Map<String, Object>> testData = generateTestData(1200000);
// When: Creating ZIP file
File result = invokeWriteResultsToCsv(testData);
// Then: Verify total row count across all files in ZIP
int totalRows = 0;
try (ZipFile zipFile = new ZipFile(result)) {
for (ZipEntry entry : Collections.list(zipFile.entries())) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zipFile.getInputStream(entry)))) {
// Count lines, excluding header
long lines = reader.lines().count() - 1;
totalRows += lines;
}
}
}
assertThat(totalRows).isEqualTo(1200000);
}
// Helper methods
private List<Map<String, Object>> generateTestData(int rows) {
List<Map<String, Object>> data = new ArrayList<>();
for (int i = 0; i < rows; i++) {
// Use LinkedHashMap to preserve order
Map<String, Object> row = new LinkedHashMap<>();
row.put("column1", "value" + i);
row.put("column2", i);
row.put("column3", "test" + i);
data.add(row);
}
return data;
}
private List<String[]> generateSqlTestData(int rows) {
List<String[]> data = new ArrayList<>();
// Add header
data.add(new String[]{"column1", "column2", "column3"});
// Add data rows
for (int i = 0; i < rows - 1; i++) {
data.add(new String[]{"value" + i, String.valueOf(i), "test" + i});
}
return data;
}
// Reflection-based invocation of private methods for testing
private File invokeWriteResultsToCsv(List<Map<String, Object>> results) throws Exception {
java.lang.reflect.Method method = DownloadService.class.getDeclaredMethod(
"writeResultsToCsv", List.class);
method.setAccessible(true);
return (File) method.invoke(downloadService, results);
}
private File invokeWriteSingleSqlCsvFile(List<String[]> results, String prefix, String suffix) throws Exception {
java.lang.reflect.Method method = DownloadService.class.getDeclaredMethod(
"writeSingleSqlCsvFile", List.class, String.class, String.class);
method.setAccessible(true);
return (File) method.invoke(downloadService, results, prefix, suffix);
}
private File invokeWriteSqlResultsToZip(List<String[]> results) throws Exception {
java.lang.reflect.Method method = DownloadService.class.getDeclaredMethod(
"writeSqlResultsToZip", List.class);
method.setAccessible(true);
return (File) method.invoke(downloadService, results);
}
}

View file

@ -0,0 +1,200 @@
package org.micord.utils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Utility class for file operations in tests.
*/
public class FileTestUtils {
/**
* Count the number of lines in a CSV file
*/
public static long countCsvLines(File csvFile) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(csvFile), StandardCharsets.UTF_8))) {
return reader.lines().count();
}
}
/**
* Read all lines from a CSV file
*/
public static List<String> readCsvLines(File csvFile) throws IOException {
return Files.readAllLines(csvFile.toPath(), StandardCharsets.UTF_8);
}
/**
* Parse CSV line (simple implementation for testing)
*/
public static String[] parseCsvLine(String line) {
List<String> result = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '"') {
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
current.append('"');
i++; // Skip next quote
} else {
inQuotes = !inQuotes;
}
} else if (c == ',' && !inQuotes) {
result.add(current.toString());
current = new StringBuilder();
} else {
current.append(c);
}
}
result.add(current.toString());
return result.toArray(new String[0]);
}
/**
* Verify CSV file structure
*/
public static void verifyCsvStructure(File csvFile, int expectedColumns) throws IOException {
List<String> lines = readCsvLines(csvFile);
assertThat(lines).isNotEmpty();
// Check header
String[] header = parseCsvLine(lines.get(0));
assertThat(header).hasSize(expectedColumns);
// Check data rows have same number of columns
for (int i = 1; i < Math.min(10, lines.size()); i++) {
String[] row = parseCsvLine(lines.get(i));
assertThat(row).hasSize(expectedColumns);
}
}
/**
* Count total rows across all CSV files in a ZIP
*/
public static long countTotalRowsInZip(File zipFile) throws IOException {
long totalRows = 0;
try (ZipFile zip = new ZipFile(zipFile)) {
for (ZipEntry entry : java.util.Collections.list(zip.entries())) {
if (entry.getName().endsWith(".csv")) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zip.getInputStream(entry), StandardCharsets.UTF_8))) {
// Count lines excluding header
long lines = reader.lines().count() - 1;
totalRows += lines;
}
}
}
}
return totalRows;
}
/**
* Extract all CSV files from a ZIP to a temporary directory
*/
public static List<File> extractCsvFromZip(File zipFile, Path tempDir) throws IOException {
List<File> extractedFiles = new ArrayList<>();
try (ZipFile zip = new ZipFile(zipFile)) {
for (ZipEntry entry : java.util.Collections.list(zip.entries())) {
if (entry.getName().endsWith(".csv")) {
Path outputPath = tempDir.resolve(entry.getName());
try (InputStream is = zip.getInputStream(entry);
OutputStream os = Files.newOutputStream(outputPath)) {
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) > 0) {
os.write(buffer, 0, len);
}
}
extractedFiles.add(outputPath.toFile());
}
}
}
return extractedFiles;
}
/**
* Verify that each CSV file in ZIP has headers
*/
public static void verifyZipCsvHeaders(File zipFile, String[] expectedHeaders) throws IOException {
try (ZipFile zip = new ZipFile(zipFile)) {
for (ZipEntry entry : java.util.Collections.list(zip.entries())) {
if (entry.getName().endsWith(".csv")) {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zip.getInputStream(entry), StandardCharsets.UTF_8))) {
String firstLine = reader.readLine();
assertThat(firstLine).isNotNull();
String[] headers = parseCsvLine(firstLine);
assertThat(headers).containsExactly(expectedHeaders);
}
}
}
}
}
/**
* Create a temporary CSV file with test data
*/
public static File createTempCsvFile(List<String[]> data, Path tempDir) throws IOException {
File csvFile = Files.createTempFile(tempDir, "test-", ".csv").toFile();
try (PrintWriter writer = new PrintWriter(
new OutputStreamWriter(new FileOutputStream(csvFile), StandardCharsets.UTF_8))) {
for (String[] row : data) {
writer.println(formatCsvRow(row));
}
}
return csvFile;
}
/**
* Format a row for CSV output
*/
private static String formatCsvRow(String[] row) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < row.length; i++) {
if (i > 0) {
result.append(",");
}
result.append("\"");
result.append(row[i] != null ? row[i].replace("\"", "\"\"") : "");
result.append("\"");
}
return result.toString();
}
/**
* Get file size in MB
*/
public static double getFileSizeInMB(File file) {
return file.length() / (1024.0 * 1024.0);
}
/**
* Verify file is within size limits
*/
public static void verifyFileSize(File file, double maxSizeMB) {
double sizeMB = getFileSizeInMB(file);
assertThat(sizeMB).isLessThanOrEqualTo(maxSizeMB);
}
}

View file

@ -0,0 +1,196 @@
package org.micord.utils;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* Utility class for generating test data for download service tests.
*/
public class TestDataGenerator {
private static final String[] FIRST_NAMES = {
"John", "Jane", "Michael", "Sarah", "David", "Emma", "Robert", "Lisa", "William", "Mary"
};
private static final String[] LAST_NAMES = {
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"
};
private static final String[] CITIES = {
"New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego"
};
/**
* Generate a list of maps representing CSV data rows
*/
public static List<Map<String, Object>> generateMapData(int rows) {
List<Map<String, Object>> data = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < rows; i++) {
Map<String, Object> row = new HashMap<>();
row.put("id", UUID.randomUUID().toString());
row.put("firstName", FIRST_NAMES[random.nextInt(FIRST_NAMES.length)]);
row.put("lastName", LAST_NAMES[random.nextInt(LAST_NAMES.length)]);
row.put("age", 18 + random.nextInt(60));
row.put("city", CITIES[random.nextInt(CITIES.length)]);
row.put("email", "user" + i + "@example.com");
row.put("phone", generatePhoneNumber());
row.put("registrationDate", generateRandomDate());
row.put("isActive", random.nextBoolean());
row.put("balance", String.format("%.2f", random.nextDouble() * 10000));
data.add(row);
}
return data;
}
/**
* Generate SQL-style array data (including headers)
*/
public static List<String[]> generateSqlData(int rows, boolean includeHeaders) {
List<String[]> data = new ArrayList<>();
Random random = new Random();
if (includeHeaders) {
data.add(new String[]{
"id", "firstName", "lastName", "age", "city",
"email", "phone", "registrationDate", "isActive", "balance"
});
}
for (int i = 0; i < rows; i++) {
String[] row = new String[]{
UUID.randomUUID().toString(),
FIRST_NAMES[random.nextInt(FIRST_NAMES.length)],
LAST_NAMES[random.nextInt(LAST_NAMES.length)],
String.valueOf(18 + random.nextInt(60)),
CITIES[random.nextInt(CITIES.length)],
"user" + i + "@example.com",
generatePhoneNumber(),
generateRandomDate().toString(),
String.valueOf(random.nextBoolean()),
String.format("%.2f", random.nextDouble() * 10000)
};
data.add(row);
}
return data;
}
/**
* Generate data with special characters that need CSV escaping
*/
public static List<Map<String, Object>> generateProblematicData() {
List<Map<String, Object>> data = new ArrayList<>();
// Row with quotes
Map<String, Object> row1 = new HashMap<>();
row1.put("id", "1");
row1.put("description", "This has \"quotes\" in it");
row1.put("name", "John \"The Boss\" Smith");
data.add(row1);
// Row with commas
Map<String, Object> row2 = new HashMap<>();
row2.put("id", "2");
row2.put("description", "Values, with, many, commas");
row2.put("name", "Smith, John");
data.add(row2);
// Row with newlines
Map<String, Object> row3 = new HashMap<>();
row3.put("id", "3");
row3.put("description", "First line\nSecond line\nThird line");
row3.put("name", "Multi\nLine\nName");
data.add(row3);
// Row with null values
Map<String, Object> row4 = new HashMap<>();
row4.put("id", "4");
row4.put("description", null);
row4.put("name", null);
data.add(row4);
// Row with special characters
Map<String, Object> row5 = new HashMap<>();
row5.put("id", "5");
row5.put("description", "Special chars: @#$%^&*()_+-=[]{}|;':\",./<>?");
row5.put("name", "Ñoño José");
data.add(row5);
// Row with tabs
Map<String, Object> row6 = new HashMap<>();
row6.put("id", "6");
row6.put("description", "Tab\tseparated\tvalues");
row6.put("name", "Tab\tUser");
data.add(row6);
return data;
}
/**
* Generate edge case datasets for boundary testing
*/
public static class EdgeCaseDatasets {
public static final int JUST_UNDER_LIMIT = 599999;
public static final int EXACTLY_AT_LIMIT = 600000;
public static final int JUST_OVER_LIMIT = 600001;
public static final int ONE_AND_HALF_CHUNKS = 900000;
public static final int EXACTLY_TWO_CHUNKS = 1200000;
public static final int MULTIPLE_CHUNKS = 2400000;
public static List<Map<String, Object>> getDataset(int size) {
return generateMapData(size);
}
}
/**
* Generate large dataset in chunks to avoid memory issues
*/
public static Iterator<Map<String, Object>> generateLargeDataIterator(int totalRows) {
return new Iterator<Map<String, Object>>() {
private int currentRow = 0;
private final Random random = new Random();
@Override
public boolean hasNext() {
return currentRow < totalRows;
}
@Override
public Map<String, Object> next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Map<String, Object> row = new HashMap<>();
row.put("id", UUID.randomUUID().toString());
row.put("rowNumber", currentRow);
row.put("timestamp", System.currentTimeMillis());
row.put("data", "Row " + currentRow + " of " + totalRows);
row.put("random", random.nextDouble());
currentRow++;
return row;
}
};
}
private static String generatePhoneNumber() {
Random random = new Random();
return String.format("(%03d) %03d-%04d",
random.nextInt(900) + 100,
random.nextInt(900) + 100,
random.nextInt(9000) + 1000);
}
private static LocalDate generateRandomDate() {
long minDay = LocalDate.of(2020, 1, 1).toEpochDay();
long maxDay = LocalDate.of(2024, 12, 31).toEpochDay();
long randomDay = ThreadLocalRandom.current().nextLong(minDay, maxDay);
return LocalDate.ofEpochDay(randomDay);
}
}

File diff suppressed because it is too large Load diff