From f6c73edf0acb83a71bbc8e2c834ab2825baada3c Mon Sep 17 00:00:00 2001 From: ilia-fischer Date: Thu, 5 Jan 2017 17:48:00 -0500 Subject: [PATCH 1/2] Wave Software Development Challenge Initial seed. Web based application accepting via a form comma separated file with the following columns: date, category, employee name, employee address, expense description, pre-tax amount, tax name, and tax amount. After upload, application processes the file, stores data in in-memory relational database and displays a table of the total expenses amount per-month represented by the uploaded file. The following assumptions are taken: i. Columns will always be in that order. ii. There will always be data in each column. iii. There will always be a header line. --- README.md | 95 ++++++++++ pom.xml | 61 ++++++ .../com/wave/csvconverter/Application.java | 33 ++++ .../persistence/CSVFileReader.java | 70 +++++++ .../ConversionBatchConfiguration.java | 91 +++++++++ .../upload/UploadStorageProperties.java | 21 +++ .../FileUploadConversionController.java | 123 ++++++++++++ .../csvconverter/domain/EmployeeExpense.java | 176 ++++++++++++++++++ .../csvconverter/domain/MonthlyExpense.java | 72 +++++++ .../exception/CSVConversionException.java | 17 ++ .../exception/upload/StorageException.java | 19 ++ .../BinaryJobCompletionMonitoringService.java | 5 + .../CSVFileConversionJobService.java | 52 ++++++ .../persistence/ConversionJobService.java | 7 + .../EmployeeExpenseDataROService.java | 12 ++ .../SimpleEmployeeExpenseDataService.java | 67 +++++++ .../SimpleJobCompletionMonitoringService.java | 24 +++ .../service/upload/FileUploadService.java | 62 ++++++ .../service/upload/UploadService.java | 15 ++ .../JobCompletionNotificationListener.java | 81 ++++++++ .../MonthlyExpensesCalculator.java | 71 +++++++ src/main/resources/application.properties | 3 + src/main/resources/schema-all.sql | 13 ++ src/main/resources/templates/expenses.html | 26 +++ src/main/resources/templates/uploadForm.html | 26 +++ .../FileUploadIntegrationTests.java | 59 ++++++ .../wave/csvconverter/FileUploadTests.java | 52 ++++++ .../MonthlyExpensesCalculatorTests.java | 142 ++++++++++++++ .../com/wave/csvconverter/testupload.txt | 1 + 29 files changed, 1496 insertions(+) create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/wave/csvconverter/Application.java create mode 100644 src/main/java/com/wave/csvconverter/configuration/persistence/CSVFileReader.java create mode 100644 src/main/java/com/wave/csvconverter/configuration/persistence/ConversionBatchConfiguration.java create mode 100644 src/main/java/com/wave/csvconverter/configuration/upload/UploadStorageProperties.java create mode 100644 src/main/java/com/wave/csvconverter/controller/FileUploadConversionController.java create mode 100644 src/main/java/com/wave/csvconverter/domain/EmployeeExpense.java create mode 100644 src/main/java/com/wave/csvconverter/domain/MonthlyExpense.java create mode 100644 src/main/java/com/wave/csvconverter/exception/CSVConversionException.java create mode 100644 src/main/java/com/wave/csvconverter/exception/upload/StorageException.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/BinaryJobCompletionMonitoringService.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/CSVFileConversionJobService.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/ConversionJobService.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/EmployeeExpenseDataROService.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/SimpleEmployeeExpenseDataService.java create mode 100644 src/main/java/com/wave/csvconverter/service/persistence/SimpleJobCompletionMonitoringService.java create mode 100644 src/main/java/com/wave/csvconverter/service/upload/FileUploadService.java create mode 100644 src/main/java/com/wave/csvconverter/service/upload/UploadService.java create mode 100644 src/main/java/com/wave/csvconverter/utils/persistence/JobCompletionNotificationListener.java create mode 100644 src/main/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculator.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/schema-all.sql create mode 100644 src/main/resources/templates/expenses.html create mode 100644 src/main/resources/templates/uploadForm.html create mode 100644 src/test/java/com/wave/csvconverter/FileUploadIntegrationTests.java create mode 100644 src/test/java/com/wave/csvconverter/FileUploadTests.java create mode 100644 src/test/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculatorTests.java create mode 100644 src/test/resources/com/wave/csvconverter/testupload.txt diff --git a/README.md b/README.md new file mode 100644 index 000000000..590ffe408 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +CVSConverter 0.1.0 +============== + +Description +-------------- + +Web based application accepting via a form comma separated file +with the following columns: date, category, employee name, employee address, expense description, pre-tax amount, tax name, and tax amount. + + +After upload, application processes the file, stores data in in-memory relational database +and displays a table of the total expenses amount per-month represented by the uploaded file. + + +The following assumptions are taken: + + +i. Columns will always be in that order. + +ii. There will always be data in each column. + +iii. There will always be a header line. + + +*Notes:* + +*The application allows parallel processing of the files.* + +*The data stored in the in-memory database is discarded after the application terminates.* + +*The records in the uploaded files are not uniquely identified and are not de-duplicated in the database. This may cause accumulation of the monthly expenses returned by the application when multiple uploads of the same or overlapping data are performed.* + +*The previously uploaded copies of the files are cleaned up when application starts.* + + +Build and run instructions +--------------------------- + +To **build** the application you will need + +JDK 1.8 or later. + +Maven 3.0+. + + +To build the application execute: + +**mvn clean install** + +To create a stand alone application package run: + +**mvn package** + +(the build JAR file will be located in the target directory.) + + +Both commands must be executed from the root application directory where *pom.xml* file resides. + + +To **run** the application you must have + +Java 1.8 or later. + +The application requires write access to the local directory. + + +To run with Maven execute: + +**mvn spring-boot:run** + +from the root application directory. + + +Alternatively to run the pre-packaged JAR file execute: + +**java -jar /csvconverter-0.1.0.jar** + + +No preliminary steps are necessary to run the application. + +Once launched it can be access with you web browser (tested with Firefox and Safari) under the following URI: + +**http://localhost:8080** + + +The application can be terminated from the command line by sending SIGTERM signal with CTRL+C. + + +Implementation notes +---------------------- + +I have completed the application within couple of nights and enjoyed every step of its development. I was particularly happy to put hands on Spring Batch projects - something I was planning to do for a while bug have not had a good reason to do it. I believe with Spring Batch and Spring Boot we can quickly build well structured applications separating different concerns and leveraging best OO practices. + +My TODOs for the next version would include improved error handling, better Unit Test coverage and separation of data between different +web session contexts. diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..0f753cf96 --- /dev/null +++ b/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.wave + csvconverter + 0.1.0 + + + org.springframework.boot + spring-boot-starter-parent + 1.4.3.RELEASE + + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-devtools + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-batch + + + org.hsqldb + hsqldb + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + 1.8 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/src/main/java/com/wave/csvconverter/Application.java b/src/main/java/com/wave/csvconverter/Application.java new file mode 100644 index 000000000..f8fc83bca --- /dev/null +++ b/src/main/java/com/wave/csvconverter/Application.java @@ -0,0 +1,33 @@ +package com.wave.csvconverter; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableAsync; + +import com.wave.csvconverter.configuration.upload.UploadStorageProperties; +import com.wave.csvconverter.service.upload.UploadService; + +@SpringBootApplication +@EnableConfigurationProperties(UploadStorageProperties.class) +@EnableAsync +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /* + * The bean initialization cleans up all leftover artifacts + * from the previous runs + */ + @Bean + CommandLineRunner init(UploadService uploadService) { + return (args) -> { + uploadService.deleteAll(); + uploadService.init(); + }; + } +} diff --git a/src/main/java/com/wave/csvconverter/configuration/persistence/CSVFileReader.java b/src/main/java/com/wave/csvconverter/configuration/persistence/CSVFileReader.java new file mode 100644 index 000000000..ce3275eec --- /dev/null +++ b/src/main/java/com/wave/csvconverter/configuration/persistence/CSVFileReader.java @@ -0,0 +1,70 @@ +package com.wave.csvconverter.configuration.persistence; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.file.FlatFileItemReader; +import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper; +import org.springframework.batch.item.file.mapping.DefaultLineMapper; +import org.springframework.batch.item.file.transform.DelimitedLineTokenizer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.stereotype.Component; + +import com.wave.csvconverter.domain.EmployeeExpense; + +/* + * Custom batch file reader. The read accepts the CSV file name + * for reading from the JobParameters. + * Each job/reader instance can read its own file to ensure parallel + * processing of multiple conversion batches. + */ +@Component +@StepScope +public class CSVFileReader extends FlatFileItemReader { + + private String fileName; + + private static final Logger log = LoggerFactory.getLogger(CSVFileReader.class); + + /* + * The CVS file name is passed to the c'tor + */ + @Autowired + public CSVFileReader(@Value("#{jobParameters['convert.file.name']}") final String fileName) { + setFileName(fileName); + initiazliazeReader(); + } + + /* + * The reader is initialized with custom line mapper used to fetch records from + * the CVS file according to the required format + */ + private void initiazliazeReader() { + log.info("Initialize CSV Reader for the file '" + fileName + "'"); + + setResource(new FileSystemResource(fileName)); + setLinesToSkip(1); // first line is title definition + setLineMapper(new DefaultLineMapper() { + { + setLineTokenizer(new DelimitedLineTokenizer() { + { + setNames(new String[] { "date", "category", "employee_name", "employee_address", + "expense_description", "pretax_amount", "tax_name", "tax_amount" }); + } + }); + setFieldSetMapper(new BeanWrapperFieldSetMapper() { + { + setTargetType(EmployeeExpense.class); + } + }); + } + }); + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + +} diff --git a/src/main/java/com/wave/csvconverter/configuration/persistence/ConversionBatchConfiguration.java b/src/main/java/com/wave/csvconverter/configuration/persistence/ConversionBatchConfiguration.java new file mode 100644 index 000000000..e95f8de4b --- /dev/null +++ b/src/main/java/com/wave/csvconverter/configuration/persistence/ConversionBatchConfiguration.java @@ -0,0 +1,91 @@ +package com.wave.csvconverter.configuration.persistence; + +import javax.sql.DataSource; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.launch.support.SimpleJobLauncher; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider; +import org.springframework.batch.item.database.JdbcBatchItemWriter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +import com.wave.csvconverter.domain.EmployeeExpense; +import com.wave.csvconverter.utils.persistence.JobCompletionNotificationListener; + +/* + * The class defines batch work-flow for CSV files conversion into RDMS + */ +@Configuration +@EnableBatchProcessing +public class ConversionBatchConfiguration { + + @Autowired + public JobBuilderFactory jobBuilderFactory; + + @Autowired + public StepBuilderFactory stepBuilderFactory; + + @Autowired + public DataSource dataSource; + + @Autowired + public JobRepository jobRepository; + + @Autowired + public CSVFileReader fileReader; + + /* + * Override the basic synchronous Job launcher to enable + * asynchronous processing of the Job which would prevent + * web requests timeouts and improve UX + */ + @Bean + public JobLauncher asyncJobLauncher() { + SimpleJobLauncher simpleJobLauncher = new SimpleJobLauncher(); + simpleJobLauncher.setJobRepository(jobRepository); + simpleJobLauncher.setTaskExecutor(new SimpleAsyncTaskExecutor()); + return simpleJobLauncher; + } + + /* + * Simple writer definition. The data is written into a default in memory + * relation database. The table is initialized each time the application starts + */ + @Bean + public JdbcBatchItemWriter writer() { + JdbcBatchItemWriter writer = new JdbcBatchItemWriter(); + writer.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider()); + writer.setSql( + "INSERT INTO employee_expenses (expense_date, category,employee_name,employee_address,expense_description,pretax_amount,tax_name,tax_amount) VALUES (:date, :category, :employee_name, :employee_address, :expense_description, :pretax_amount, :tax_name, :tax_amount)"); + writer.setDataSource(dataSource); + return writer; + } + + /* + * Register custom Job completion notification listener which would tell us when + * a conversion Job is done + */ + @Bean + public Job job(JobCompletionNotificationListener listener) { + return jobBuilderFactory.get("importEmployeeExpenseJob").incrementer(new RunIdIncrementer()).listener(listener) + .flow(step1()).end().build(); + } + + /* + * Conversion step definition + */ + @Bean + public Step step1() { + return stepBuilderFactory.get("step1").chunk(1).reader(fileReader) + .writer(writer()).build(); + } +} diff --git a/src/main/java/com/wave/csvconverter/configuration/upload/UploadStorageProperties.java b/src/main/java/com/wave/csvconverter/configuration/upload/UploadStorageProperties.java new file mode 100644 index 000000000..6332f42e3 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/configuration/upload/UploadStorageProperties.java @@ -0,0 +1,21 @@ +package com.wave.csvconverter.configuration.upload; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("storage") +public class UploadStorageProperties { + + /** + * Root folder location for temporary storage of the uploaded files + */ + private String location = "upload-dir"; + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + +} diff --git a/src/main/java/com/wave/csvconverter/controller/FileUploadConversionController.java b/src/main/java/com/wave/csvconverter/controller/FileUploadConversionController.java new file mode 100644 index 000000000..0ca03e53b --- /dev/null +++ b/src/main/java/com/wave/csvconverter/controller/FileUploadConversionController.java @@ -0,0 +1,123 @@ +package com.wave.csvconverter.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import com.wave.csvconverter.domain.MonthlyExpense; +import com.wave.csvconverter.exception.CSVConversionException; +import com.wave.csvconverter.service.persistence.BinaryJobCompletionMonitoringService; +import com.wave.csvconverter.service.persistence.ConversionJobService; +import com.wave.csvconverter.service.persistence.EmployeeExpenseDataROService; +import com.wave.csvconverter.service.upload.UploadService; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.List; + +/* + * Web controller class - handles and processes HTTP requests using + * the underlying services + */ +@Controller +public class FileUploadConversionController { + + private final UploadService uploadService; + private final ConversionJobService conversionService; + private final EmployeeExpenseDataROService employeeExpenseDataService; + private final BinaryJobCompletionMonitoringService jobCompletionMonitoringService; + + private static final Logger log = LoggerFactory.getLogger(FileUploadConversionController.class); + + @Autowired + public FileUploadConversionController(UploadService storageService, ConversionJobService conversionService, + EmployeeExpenseDataROService employeeExpenseDataService, + BinaryJobCompletionMonitoringService jobCompletionMonitoringService) { + this.uploadService = storageService; + this.conversionService = conversionService; + this.employeeExpenseDataService = employeeExpenseDataService; + this.jobCompletionMonitoringService = jobCompletionMonitoringService; + } + + /* + * Initial view showing the upload form + */ + @GetMapping("/") + public String listUploadedFiles(Model model) throws IOException { + + return "uploadForm"; + } + + /* + * Upload CSV file request handler. Once the file is uploaded the client + * is redirected to the waiting page which polls for the conversion completion + * notification + */ + @PostMapping("/") + public String handleFileUpload(@RequestParam("file") MultipartFile file) { + + Path randomFileLocation = uploadService.store(file); + conversionService.launchConversionJob(randomFileLocation); + + return "redirect:/wait-tables/" + randomFileLocation.toString(); + } + + /* + * CSV File conversion processing waiting handler. + * Simplistic completion waiting pattern using polling for the completion status. + * We prevent browser from timing out while asynchronous conversion job is running. + * Client is redirected back to the waiting pattern as long as the file conversion is running. + * Client is redirected to the table with the desired data upon conversion completion. + * The handler accepts unique conversion file identification to distinguish between parallel + * conversion requests using path variables. Clients are redirected here after + * successful file upload request completion. + */ + @GetMapping("/wait-tables/{rootdirname:.+}/{randdirname:.+}/{filename:.+}") + public String waitRenderRDBMSData(@PathVariable String rootdirname, @PathVariable String randdirname, + @PathVariable String filename) { + + Path filePath = FileSystems.getDefault().getPath(rootdirname, randdirname).resolve(filename); + if (jobCompletionMonitoringService.isJobCompleted(filePath.toString())) { + return "redirect:/tables/" + filePath.toString(); + } else { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + log.error("Unable to throttle job status polling: " + e.getMessage()); + } + return "redirect:/wait-tables/" + filePath.toString(); + } + } + + /* + * Handler for the request to show rendered data for the unique CVS file + * which identification is passed as a path variable. Clients are redirected here after + * the waiting handler detects that the conversion Job is completed. + */ + @GetMapping("/tables/{rootdirname:.+}/{randdirname:.+}/{filename:.+}") + public String renderRDBMSData(@PathVariable String rootdirname, @PathVariable String randdirname, + @PathVariable String filename, Model model) { + + List monthlyExpenses = employeeExpenseDataService.getTotalMonthlyExpenses(); + + model.addAttribute("expenses", monthlyExpenses); + return "expenses"; + } + + /* + * Simple controler's exception handler. Returns 500 HTTP code. + */ + @SuppressWarnings("rawtypes") + @ExceptionHandler(CSVConversionException.class) + public ResponseEntity handleAllException(Exception ex) { + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/src/main/java/com/wave/csvconverter/domain/EmployeeExpense.java b/src/main/java/com/wave/csvconverter/domain/EmployeeExpense.java new file mode 100644 index 000000000..64d382175 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/domain/EmployeeExpense.java @@ -0,0 +1,176 @@ +package com.wave.csvconverter.domain; + +import java.util.Date; + +/* + * POJO model for the Employee Expense record in the database + */ +public class EmployeeExpense { + private Date date; + private String category; + private String employee_name; + private String employee_address; + private String expense_description; + private String pretax_amount; + private String tax_name; + private String tax_amount; + + public EmployeeExpense() { + + } + + public EmployeeExpense(Date date, String category, String employee_name, String employee_address, + String expense_description, String pretax_amount, String tax_name, String tax_amount) { + setDate(date); + setCategory(category); + setEmployee_name(employee_name); + setEmployee_address(employee_address); + setExpense_description(expense_description); + setPretax_amount(pretax_amount); + setTax_name(tax_name); + setTax_amount(tax_amount); + } + + public Date getDate() { + return date; + } + + public void setDate(Date date) { + this.date = date; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getEmployee_name() { + return employee_name; + } + + public void setEmployee_name(String employee_name) { + this.employee_name = employee_name; + } + + public String getEmployee_address() { + return employee_address; + } + + public void setEmployee_address(String employee_address) { + this.employee_address = employee_address; + } + + public String getExpense_description() { + return expense_description; + } + + public void setExpense_description(String expense_description) { + this.expense_description = expense_description; + } + + public String getPretax_amount() { + return pretax_amount; + } + + public void setPretax_amount(String pretax_amount) { + this.pretax_amount = pretax_amount; + } + + public String getTax_name() { + return tax_name; + } + + public void setTax_name(String tax_name) { + this.tax_name = tax_name; + } + + public String getTax_amount() { + return tax_amount; + } + + public void setTax_amount(String tax_amount) { + this.tax_amount = tax_amount; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("EmployeeTax [date=").append(date).append(", category=").append(category) + .append(", employee_name=").append(employee_name).append(", employee_address=").append(employee_address) + .append(", expense_description=").append(expense_description).append(", pretax_amount=") + .append(pretax_amount).append(", tax_name=").append(tax_name).append(", tax_amount=").append(tax_amount) + .append("]"); + return builder.toString(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((category == null) ? 0 : category.hashCode()); + result = prime * result + ((date == null) ? 0 : date.hashCode()); + result = prime * result + ((employee_address == null) ? 0 : employee_address.hashCode()); + result = prime * result + ((employee_name == null) ? 0 : employee_name.hashCode()); + result = prime * result + ((expense_description == null) ? 0 : expense_description.hashCode()); + result = prime * result + ((pretax_amount == null) ? 0 : pretax_amount.hashCode()); + result = prime * result + ((tax_amount == null) ? 0 : tax_amount.hashCode()); + result = prime * result + ((tax_name == null) ? 0 : tax_name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + EmployeeExpense other = (EmployeeExpense) obj; + if (category == null) { + if (other.category != null) + return false; + } else if (!category.equals(other.category)) + return false; + if (date == null) { + if (other.date != null) + return false; + } else if (!date.equals(other.date)) + return false; + if (employee_address == null) { + if (other.employee_address != null) + return false; + } else if (!employee_address.equals(other.employee_address)) + return false; + if (employee_name == null) { + if (other.employee_name != null) + return false; + } else if (!employee_name.equals(other.employee_name)) + return false; + if (expense_description == null) { + if (other.expense_description != null) + return false; + } else if (!expense_description.equals(other.expense_description)) + return false; + if (pretax_amount == null) { + if (other.pretax_amount != null) + return false; + } else if (!pretax_amount.equals(other.pretax_amount)) + return false; + if (tax_amount == null) { + if (other.tax_amount != null) + return false; + } else if (!tax_amount.equals(other.tax_amount)) + return false; + if (tax_name == null) { + if (other.tax_name != null) + return false; + } else if (!tax_name.equals(other.tax_name)) + return false; + return true; + } + +} diff --git a/src/main/java/com/wave/csvconverter/domain/MonthlyExpense.java b/src/main/java/com/wave/csvconverter/domain/MonthlyExpense.java new file mode 100644 index 000000000..dede6af5f --- /dev/null +++ b/src/main/java/com/wave/csvconverter/domain/MonthlyExpense.java @@ -0,0 +1,72 @@ +package com.wave.csvconverter.domain; + +import java.util.Date; + +/* + * POJO model of the object returned to clients which includes information + * about monthly employees' expenses + */ +public class MonthlyExpense { + private Date monthYear; + private Double expenses; + + public MonthlyExpense(Date monthYear, Double expenses) { + setMonthYear(monthYear); + setExpenses(expenses); + } + + public Date getMonthYear() { + return monthYear; + } + + public void setMonthYear(Date monthYear) { + this.monthYear = monthYear; + } + + public Double getExpenses() { + return expenses; + } + + public void setExpenses(Double expenses) { + this.expenses = expenses; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("MonthlyExpense [monthYear=").append(monthYear).append(", expenses=").append(expenses) + .append("]"); + return builder.toString(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((expenses == null) ? 0 : expenses.hashCode()); + result = prime * result + ((monthYear == null) ? 0 : monthYear.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + MonthlyExpense other = (MonthlyExpense) obj; + if (expenses == null) { + if (other.expenses != null) + return false; + } else if (!expenses.equals(other.expenses)) + return false; + if (monthYear == null) { + if (other.monthYear != null) + return false; + } else if (!monthYear.equals(other.monthYear)) + return false; + return true; + } +} diff --git a/src/main/java/com/wave/csvconverter/exception/CSVConversionException.java b/src/main/java/com/wave/csvconverter/exception/CSVConversionException.java new file mode 100644 index 000000000..f63decec7 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/exception/CSVConversionException.java @@ -0,0 +1,17 @@ +package com.wave.csvconverter.exception; + +/* + * Application customized base exception object + */ +public class CSVConversionException extends RuntimeException { + + private static final long serialVersionUID = 2588062449284656665L; + + public CSVConversionException(String message) { + super(message); + } + + public CSVConversionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/wave/csvconverter/exception/upload/StorageException.java b/src/main/java/com/wave/csvconverter/exception/upload/StorageException.java new file mode 100644 index 000000000..292181e52 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/exception/upload/StorageException.java @@ -0,0 +1,19 @@ +package com.wave.csvconverter.exception.upload; + +import com.wave.csvconverter.exception.CSVConversionException; + +/* + * Custom exception object for the files upload and storage operations + */ +public class StorageException extends CSVConversionException { + + private static final long serialVersionUID = 6651010547266455529L; + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/BinaryJobCompletionMonitoringService.java b/src/main/java/com/wave/csvconverter/service/persistence/BinaryJobCompletionMonitoringService.java new file mode 100644 index 000000000..65a1a3f48 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/BinaryJobCompletionMonitoringService.java @@ -0,0 +1,5 @@ +package com.wave.csvconverter.service.persistence; + +public interface BinaryJobCompletionMonitoringService { + boolean isJobCompleted(String jobId); +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/CSVFileConversionJobService.java b/src/main/java/com/wave/csvconverter/service/persistence/CSVFileConversionJobService.java new file mode 100644 index 000000000..7458ccce8 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/CSVFileConversionJobService.java @@ -0,0 +1,52 @@ +/** + * + */ +package com.wave.csvconverter.service.persistence; + +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import com.wave.csvconverter.exception.CSVConversionException; + +/* + * The service is responsible for the files' conversion jobs + */ +@Service +public class CSVFileConversionJobService implements ConversionJobService { + + @Autowired + @Qualifier("asyncJobLauncher") + private JobLauncher jobLauncher; + + @Autowired + private Job job; + + private static final Logger log = LoggerFactory.getLogger(CSVFileConversionJobService.class); + + /* + * Launch new file conversion Job which instance is uniquely identified by the uniquely + * generated stored file path. The Job is launched asynchronously as per defined Job launcher. + * The path is passed to the Job within the Job parameters + */ + @Override + public void launchConversionJob(Path file) { + log.info("Launching Job for the file '" + file.toString() + "'"); + try { + JobParameters jobParameters = new JobParametersBuilder().addString("convert.file.name", file.toString()) + .toJobParameters(); + jobLauncher.run(job, jobParameters); + } catch (Exception e) { + log.error("Unable to launch data conversion job for " + file.toString() + ": " + e.getMessage()); + throw new CSVConversionException("Unable to launch data conversion job for " + file.toString(), e); + } + } +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/ConversionJobService.java b/src/main/java/com/wave/csvconverter/service/persistence/ConversionJobService.java new file mode 100644 index 000000000..8194bb509 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/ConversionJobService.java @@ -0,0 +1,7 @@ +package com.wave.csvconverter.service.persistence; + +import java.nio.file.Path; + +public interface ConversionJobService { + void launchConversionJob(Path file); +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/EmployeeExpenseDataROService.java b/src/main/java/com/wave/csvconverter/service/persistence/EmployeeExpenseDataROService.java new file mode 100644 index 000000000..bb44d7997 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/EmployeeExpenseDataROService.java @@ -0,0 +1,12 @@ +package com.wave.csvconverter.service.persistence; + +import java.util.List; + +import com.wave.csvconverter.domain.EmployeeExpense; +import com.wave.csvconverter.domain.MonthlyExpense; + +public interface EmployeeExpenseDataROService { + List getAllEmployeeExpenses(); + + List getTotalMonthlyExpenses(); +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/SimpleEmployeeExpenseDataService.java b/src/main/java/com/wave/csvconverter/service/persistence/SimpleEmployeeExpenseDataService.java new file mode 100644 index 000000000..c684d85e3 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/SimpleEmployeeExpenseDataService.java @@ -0,0 +1,67 @@ +package com.wave.csvconverter.service.persistence; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import com.wave.csvconverter.domain.EmployeeExpense; +import com.wave.csvconverter.domain.MonthlyExpense; +import com.wave.csvconverter.utils.persistence.MonthlyExpensesCalculator; + +/* + * The service manages access to the Employees' expenses table in the database + */ +@Service +public class SimpleEmployeeExpenseDataService implements EmployeeExpenseDataROService { + private final JdbcTemplate jdbcTemplate; + + private static final Logger log = LoggerFactory.getLogger(SimpleEmployeeExpenseDataService.class); + + @Autowired + public SimpleEmployeeExpenseDataService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /* + * Retrieve all the table records + */ + public List getAllEmployeeExpenses() { + log.info("Retrieveing all employees' expense records"); + List results = jdbcTemplate.query( + "SELECT expense_date,category,employee_name,employee_address,expense_description,pretax_amount,tax_name,tax_amount FROM employee_expenses", + new RowMapper() { + @Override + public EmployeeExpense mapRow(ResultSet rs, int row) throws SQLException { + return new EmployeeExpense(rs.getDate(1), rs.getString(2), rs.getString(3), rs.getString(4), + rs.getString(5), rs.getString(6), rs.getString(7), rs.getString(8)); + } + }); + // For debugging purposes + for (EmployeeExpense expense : results) { + log.info("Returned employee expense record <" + expense + "> from the database."); + } + + return results; + } + + /* + * Return monthly expenses calculated using the helper utility class + */ + public List getTotalMonthlyExpenses() { + List eployeeExpenses = getAllEmployeeExpenses(); + + List results = new MonthlyExpensesCalculator().getTotalMonthlyExpenses(eployeeExpenses); + // For debugging purposes + for (MonthlyExpense monExpense : results) { + log.info("Returned monthly expense data <" + monExpense + "> based on the database records."); + } + return results; + } +} diff --git a/src/main/java/com/wave/csvconverter/service/persistence/SimpleJobCompletionMonitoringService.java b/src/main/java/com/wave/csvconverter/service/persistence/SimpleJobCompletionMonitoringService.java new file mode 100644 index 000000000..5f34eb629 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/persistence/SimpleJobCompletionMonitoringService.java @@ -0,0 +1,24 @@ +package com.wave.csvconverter.service.persistence; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.wave.csvconverter.utils.persistence.JobCompletionNotificationListener; + +/* + * The service provide the Web controller information about the status of a particular Job + */ +@Service +public class SimpleJobCompletionMonitoringService implements BinaryJobCompletionMonitoringService { + + private final JobCompletionNotificationListener jobCompletionListener; + + @Autowired + public SimpleJobCompletionMonitoringService(JobCompletionNotificationListener jobCompletionListener) { + this.jobCompletionListener = jobCompletionListener; + } + + public boolean isJobCompleted(String jobId) { + return jobCompletionListener.isJobInCompletedSet(jobId); + } +} diff --git a/src/main/java/com/wave/csvconverter/service/upload/FileUploadService.java b/src/main/java/com/wave/csvconverter/service/upload/FileUploadService.java new file mode 100644 index 000000000..d8600a14e --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/upload/FileUploadService.java @@ -0,0 +1,62 @@ +package com.wave.csvconverter.service.upload; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.FileSystemUtils; +import org.springframework.web.multipart.MultipartFile; + +import com.wave.csvconverter.configuration.upload.UploadStorageProperties; +import com.wave.csvconverter.exception.upload.StorageException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +/* + * The service manages file upload and storage operations + * called by the web controller + */ +@Service +public class FileUploadService implements UploadService { + + private final Path rootLocation; + + @Autowired + public FileUploadService(UploadStorageProperties properties) { + this.rootLocation = Paths.get(properties.getLocation()); + } + + @Override + public Path store(MultipartFile file) { + try { + if (file.isEmpty()) { + throw new StorageException("Unable to store empty file " + file.getOriginalFilename()); + } + // Create a unique location for the file to ensure concurrent files + // processing by the web controllers + String randomDirName = UUID.randomUUID().toString(); + Path randomDirLocation = this.rootLocation.resolve(randomDirName); + Path randomFileLocation = randomDirLocation.resolve(file.getOriginalFilename()); + Files.createDirectory(randomDirLocation); + Files.copy(file.getInputStream(), randomFileLocation); + return randomFileLocation; + } catch (IOException e) { + throw new StorageException("Unable to store file " + file.getOriginalFilename(), e); + } + } + + @Override + public void deleteAll() { + FileSystemUtils.deleteRecursively(rootLocation.toFile()); + } + + @Override + public void init() { + try { + Files.createDirectory(rootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize storage", e); + } + } +} diff --git a/src/main/java/com/wave/csvconverter/service/upload/UploadService.java b/src/main/java/com/wave/csvconverter/service/upload/UploadService.java new file mode 100644 index 000000000..059454e7e --- /dev/null +++ b/src/main/java/com/wave/csvconverter/service/upload/UploadService.java @@ -0,0 +1,15 @@ +package com.wave.csvconverter.service.upload; + +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; + +public interface UploadService { + + void init(); + + Path store(MultipartFile file); + + void deleteAll(); + +} diff --git a/src/main/java/com/wave/csvconverter/utils/persistence/JobCompletionNotificationListener.java b/src/main/java/com/wave/csvconverter/utils/persistence/JobCompletionNotificationListener.java new file mode 100644 index 000000000..3448002e4 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/utils/persistence/JobCompletionNotificationListener.java @@ -0,0 +1,81 @@ +package com.wave.csvconverter.utils.persistence; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.listener.JobExecutionListenerSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +import com.wave.csvconverter.domain.EmployeeExpense; + +/* + * The listener accepts notifications about the completed Jobs + * and enables services to poll for a Job status + */ +@Component +public class JobCompletionNotificationListener extends JobExecutionListenerSupport { + + private static final Logger log = LoggerFactory.getLogger(JobCompletionNotificationListener.class); + + private final JdbcTemplate jdbcTemplate; + + private Set comlpetedJobsSet = Collections.synchronizedSet(new HashSet()); + + @Autowired + public JobCompletionNotificationListener(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /* + * Job completion notification handler. Registers completed Jobs + */ + @Override + public void afterJob(JobExecution jobExecution) { + if (jobExecution.getStatus() == BatchStatus.COMPLETED) { + String jobId = jobExecution.getJobParameters().getString("convert.file.name"); + // The completed Job status is registered in a synchronized Set by + // the unique Job Id, + // which is the unique converted file name + addCompleteJobToSet(jobId); + // Rest of this function is here for debugging only + log.info("!!! JOB [" + jobId + "] FINISHED! Time to verify the results"); + + List results = jdbcTemplate.query( + "SELECT expense_date,category,employee_name,employee_address,expense_description,pretax_amount,tax_name,tax_amount FROM employee_expenses", + new RowMapper() { + @Override + public EmployeeExpense mapRow(ResultSet rs, int row) throws SQLException { + return new EmployeeExpense(rs.getDate(1), rs.getString(2), rs.getString(3), rs.getString(4), + rs.getString(5), rs.getString(6), rs.getString(7), rs.getString(8)); + } + }); + + for (EmployeeExpense expense : results) { + log.info("Found <" + expense + "> in the database."); + } + } + } + + private void addCompleteJobToSet(String jobId) { + comlpetedJobsSet.add(jobId); + } + + /* + * Jobs status accessor for the services + */ + public boolean isJobInCompletedSet(String jobId) { + return comlpetedJobsSet.contains(jobId); + } +} diff --git a/src/main/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculator.java b/src/main/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculator.java new file mode 100644 index 000000000..7c4bd9936 --- /dev/null +++ b/src/main/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculator.java @@ -0,0 +1,71 @@ +package com.wave.csvconverter.utils.persistence; + +import java.text.NumberFormat; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.SortedMap; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.wave.csvconverter.domain.EmployeeExpense; +import com.wave.csvconverter.domain.MonthlyExpense; +import com.wave.csvconverter.exception.CSVConversionException; + +/* + * Helper utility class which process Employee Expenses data + * and calculates total monthly expanses based on the date + * pre-tax and tax amounts. + */ +public class MonthlyExpensesCalculator { + + private static final Logger log = LoggerFactory.getLogger(MonthlyExpensesCalculator.class); + + public List getTotalMonthlyExpenses(List eployeeExpenses) { + Comparator dateMonthComparator = new Comparator() { + @Override + public int compare(Date date1, Date date2) { + Calendar cal1 = Calendar.getInstance(); + Calendar cal2 = Calendar.getInstance(); + cal1.setTime(date1); + cal2.setTime(date2); + return (cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) + && cal1.get(Calendar.MONTH) == cal2.get(Calendar.MONTH)) ? 0 : date1.compareTo(date2); + } + }; + // Sort the expenses' dates in the Set + SortedMap monthlyExpenses = new TreeMap(dateMonthComparator); + Calendar calendar = Calendar.getInstance(); + NumberFormat format = NumberFormat.getInstance(Locale.US); + for (EmployeeExpense expense : eployeeExpenses) { + calendar.setTime(expense.getDate()); + // When storing the month dates keep them consistently set to the + // first day of the month at 0:0:0 o'clock + calendar.set(Calendar.DAY_OF_MONTH, 1); + + MonthlyExpense oldValue = monthlyExpenses.get(calendar.getTime()); + MonthlyExpense newValue; + try { + newValue = (oldValue == null) + ? new MonthlyExpense(calendar.getTime(), + format.parse(expense.getPretax_amount()).doubleValue() + + format.parse(expense.getTax_amount()).doubleValue()) + : new MonthlyExpense(oldValue.getMonthYear(), + oldValue.getExpenses() + format.parse(expense.getPretax_amount()).doubleValue() + + format.parse(expense.getTax_amount()).doubleValue()); + monthlyExpenses.put(calendar.getTime(), newValue); + } catch (ParseException e) { + log.error("Unable to parse monthly expenses: " + e.getMessage()); + throw new CSVConversionException("Unable to parse monthly expense", e); + } + + } + return new ArrayList(monthlyExpenses.values()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 000000000..7fcaa21d2 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.http.multipart.max-file-size=128KB +spring.http.multipart.max-request-size=128KB +spring.batch.job.enabled=false \ No newline at end of file diff --git a/src/main/resources/schema-all.sql b/src/main/resources/schema-all.sql new file mode 100644 index 000000000..12ff37b59 --- /dev/null +++ b/src/main/resources/schema-all.sql @@ -0,0 +1,13 @@ +DROP TABLE employee_expenses IF EXISTS; + +CREATE TABLE employee_expenses ( + employee_tax_id BIGINT IDENTITY NOT NULL PRIMARY KEY, + expense_date DATE, + category VARCHAR(32), + employee_name VARCHAR(64), + employee_address VARCHAR(128), + expense_description VARCHAR(128), + pretax_amount VARCHAR(20), + tax_name VARCHAR(20), + tax_amount VARCHAR(20) +); \ No newline at end of file diff --git a/src/main/resources/templates/expenses.html b/src/main/resources/templates/expenses.html new file mode 100644 index 000000000..154eacb99 --- /dev/null +++ b/src/main/resources/templates/expenses.html @@ -0,0 +1,26 @@ + + + + + Monthly Expenses + + +
+
+

Total Expenses Amount Per-Month

+ + + + + + + + + +
MonthExpenses
MonthExpenses
+ +
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/uploadForm.html b/src/main/resources/templates/uploadForm.html new file mode 100644 index 000000000..a8c935ab8 --- /dev/null +++ b/src/main/resources/templates/uploadForm.html @@ -0,0 +1,26 @@ + + + +
+

+

+ +
+
+ + + +
File to upload:
+
+
+ + + + + diff --git a/src/test/java/com/wave/csvconverter/FileUploadIntegrationTests.java b/src/test/java/com/wave/csvconverter/FileUploadIntegrationTests.java new file mode 100644 index 000000000..c55d51fa8 --- /dev/null +++ b/src/test/java/com/wave/csvconverter/FileUploadIntegrationTests.java @@ -0,0 +1,59 @@ +package com.wave.csvconverter; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.embedded.LocalServerPort; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; + +import com.wave.csvconverter.service.persistence.ConversionJobService; +import com.wave.csvconverter.service.upload.UploadService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.then; +import static org.mockito.Matchers.any; + +import java.nio.file.Path; +import java.nio.file.Paths; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class FileUploadIntegrationTests { + + @Autowired + private TestRestTemplate restTemplate; + + @MockBean + private UploadService uploadService; + + @MockBean + private ConversionJobService conversionService; + + @LocalServerPort + private int port; + + @Test + public void shouldUploadFile() throws Exception { + ClassPathResource resource = new ClassPathResource("testupload.txt", getClass()); + doNothing().when(this.conversionService).launchConversionJob(any(Path.class)); + doReturn(Paths.get("testupload.txt")).when(this.uploadService).store(any(MultipartFile.class)); + + MultiValueMap map = new LinkedMultiValueMap(); + map.add("file", resource); + ResponseEntity response = this.restTemplate.postForEntity("/", map, String.class); + + assertThat(response.getStatusCode()).isEqualByComparingTo(HttpStatus.FOUND); + assertThat(response.getHeaders().getLocation().toString()).startsWith("http://localhost:" + this.port + "/"); + then(uploadService).should().store(any(MultipartFile.class)); + } +} diff --git a/src/test/java/com/wave/csvconverter/FileUploadTests.java b/src/test/java/com/wave/csvconverter/FileUploadTests.java new file mode 100644 index 000000000..cced03a00 --- /dev/null +++ b/src/test/java/com/wave/csvconverter/FileUploadTests.java @@ -0,0 +1,52 @@ +package com.wave.csvconverter; + +import org.junit.Test; +import org.junit.runner.RunWith; +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.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.multipart.MultipartFile; + +import com.wave.csvconverter.service.persistence.ConversionJobService; +import com.wave.csvconverter.service.upload.UploadService; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@RunWith(SpringRunner.class) +@AutoConfigureMockMvc +@SpringBootTest +public class FileUploadTests { + + @Autowired + private MockMvc mvc; + + @MockBean + private UploadService uploadService; + + @MockBean + private ConversionJobService convertionService; + + @Test + public void shouldSaveUploadedFile() throws Exception { + MockMultipartFile multipartFile = new MockMultipartFile("file", "test.txt", "text/plain", + "Spring Framework".getBytes()); + doNothing().when(this.convertionService).launchConversionJob(any(Path.class)); + doReturn(Paths.get("test.txt")).when(this.uploadService).store(any(MultipartFile.class)); + this.mvc.perform(fileUpload("/").file(multipartFile)).andExpect(status().isFound()) + .andExpect(header().string("Location", "/wait-tables/test.txt")); + + then(this.uploadService).should().store(multipartFile); + } +} diff --git a/src/test/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculatorTests.java b/src/test/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculatorTests.java new file mode 100644 index 000000000..fefbc8eae --- /dev/null +++ b/src/test/java/com/wave/csvconverter/utils/persistence/MonthlyExpensesCalculatorTests.java @@ -0,0 +1,142 @@ +package com.wave.csvconverter.utils.persistence; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.context.junit4.SpringRunner; + +import com.wave.csvconverter.domain.EmployeeExpense; +import com.wave.csvconverter.domain.MonthlyExpense; + +@RunWith(SpringRunner.class) +@AutoConfigureMockMvc +public class MonthlyExpensesCalculatorTests { + @Test + public void testGetTotalMonthlyExpenses_emptyList() throws Exception { + MonthlyExpensesCalculator expensesCalculator = new MonthlyExpensesCalculator(); + List eployeeExpenses = new ArrayList(); + assertTrue(expensesCalculator.getTotalMonthlyExpenses(eployeeExpenses).isEmpty()); + } + + @Test + public void testGetTotalMonthlyExpenses_sameMonthList() throws Exception { + MonthlyExpensesCalculator expensesCalculator = new MonthlyExpensesCalculator(); + List eployeeExpenses = new ArrayList(); + Calendar calendar = Calendar.getInstance(); + Date date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "100.10", "HST", "13.013")); + calendar.setTime(date); + calendar.set(Calendar.DAY_OF_MONTH, 2); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "1,000.00", "HST", "130.00")); + calendar.set(Calendar.DAY_OF_MONTH, 1); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "10,000.00", "HST", "1,300.00")); + + List monthlyExpenses = expensesCalculator.getTotalMonthlyExpenses(eployeeExpenses); + assertEquals(1, monthlyExpenses.size()); + + MonthlyExpense expense1 = monthlyExpenses.get(0); + assertEquals(date, expense1.getMonthYear()); + assertEquals((Double) 12543.113, expense1.getExpenses()); + } + + @Test + public void testGetTotalMonthlyExpenses_differentMonthsList() throws Exception { + MonthlyExpensesCalculator expensesCalculator = new MonthlyExpensesCalculator(); + List eployeeExpenses = new ArrayList(); + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.MONTH, 1); + Date date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "100.00", "HST", "13.00")); + calendar.setTime(date); + calendar.set(Calendar.DAY_OF_MONTH, 2); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "1,000.00", "HST", "130.00")); + calendar.set(Calendar.DAY_OF_MONTH, 1); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "10,000.00", "HST", "1,300.00")); + + calendar.set(Calendar.MONTH, 2); + Date date2 = calendar.getTime(); + + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "100.00", "HST", "13.00")); + calendar.set(Calendar.DAY_OF_MONTH, 2); + date2 = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "1,000.00", "HST", "130.00")); + calendar.set(Calendar.DAY_OF_MONTH, 1); + date2 = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "10,000.00", "HST", "1,300.00")); + + List monthlyExpenses = expensesCalculator.getTotalMonthlyExpenses(eployeeExpenses); + assertEquals(2, monthlyExpenses.size()); + + MonthlyExpense expense1 = monthlyExpenses.get(0); + assertEquals(date, expense1.getMonthYear()); + assertEquals((Double) 12543.0, expense1.getExpenses()); + + MonthlyExpense expense2 = monthlyExpenses.get(1); + assertEquals(date2, expense2.getMonthYear()); + assertEquals((Double) 12543.0, expense2.getExpenses()); + } + + @Test + public void testGetTotalMonthlyExpenses_differentYearsList() throws Exception { + MonthlyExpensesCalculator expensesCalculator = new MonthlyExpensesCalculator(); + List eployeeExpenses = new ArrayList(); + Calendar calendar = Calendar.getInstance(); + Date date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "100.00", "HST", "13.00")); + calendar.setTime(date); + calendar.set(Calendar.DAY_OF_MONTH, 2); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "1,000.00", "HST", "130.00")); + calendar.set(Calendar.DAY_OF_MONTH, 1); + date = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date, "category", "employee name", "employee address", + "employee description", "10,000.00", "HST", "1,300.00")); + + calendar.set(Calendar.YEAR, calendar.get(Calendar.YEAR) + 1); + Date date2 = calendar.getTime(); + + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "100.00", "HST", "13.00")); + calendar.set(Calendar.DAY_OF_MONTH, 2); + date2 = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "1,000.00", "HST", "130.00")); + calendar.set(Calendar.DAY_OF_MONTH, 1); + date2 = calendar.getTime(); + eployeeExpenses.add(new EmployeeExpense(date2, "category", "employee name", "employee address", + "employee description", "10,000.00", "HST", "1,300.00")); + + List monthlyExpenses = expensesCalculator.getTotalMonthlyExpenses(eployeeExpenses); + assertEquals(2, monthlyExpenses.size()); + + MonthlyExpense expense1 = monthlyExpenses.get(0); + assertEquals(date, expense1.getMonthYear()); + assertEquals((Double) 12543.0, expense1.getExpenses()); + + MonthlyExpense expense2 = monthlyExpenses.get(1); + assertEquals(date2, expense2.getMonthYear()); + assertEquals((Double) 12543.0, expense2.getExpenses()); + } +} diff --git a/src/test/resources/com/wave/csvconverter/testupload.txt b/src/test/resources/com/wave/csvconverter/testupload.txt new file mode 100644 index 000000000..5a0f0456d --- /dev/null +++ b/src/test/resources/com/wave/csvconverter/testupload.txt @@ -0,0 +1 @@ +Wave CSV Converter test data \ No newline at end of file From f043a3bdacbd29420086455d7bbf52334319618f Mon Sep 17 00:00:00 2001 From: ilia-fischer Date: Thu, 5 Jan 2017 21:05:42 -0500 Subject: [PATCH 2/2] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 590ffe408..aa1f5a987 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -CVSConverter 0.1.0 +CSVConverter 0.1.0 ============== Description