From 4d0e18c1a6239cfef264a533bee33a4bc2b6a71f Mon Sep 17 00:00:00 2001 From: Prince Date: Sat, 20 Jun 2026 02:33:37 +0200 Subject: [PATCH] added batch file upload feature --- Dockerfile | 9 +- .../controllers/RecipientGroupController.java | 22 + .../domain/dtos/GroupBatchCreateRequest.java | 1 - .../dtos/GroupCreateFromFileRequest.java | 22 + .../dtos/GroupCreateFromFileResult.java | 29 ++ .../{SBZProductDto.java => ProductDto.java} | 2 +- .../qantra/tm/domain/models/Transaction.java | 2 + .../repositories/ProviderRepository.java | 3 + .../domain/services/BatchReportService.java | 21 +- .../qantra/tm/domain/services/JobService.java | 3 +- .../tm/domain/services/ProviderService.java | 86 ++-- .../services/RecipientGroupService.java | 365 +++++++++++++++- .../ZesaConfirmationHotProcessor.java | 13 +- .../HotRechargeIntegrationProcessor.java | 30 +- .../resources/liquibase-diff-changeLog.xml | 27 +- .../java/zw/qantra/tm/TmApplicationTests.java | 14 + .../TransactionRepositoryTest.java | 155 +++++++ .../services/TransactionServiceTest.java | 167 ++++++++ .../handlers/CalculateChargesHandlerTest.java | 401 ++++++++++++++++++ .../handlers/CleanupHandlerTest.java | 185 ++++++++ .../handlers/ConfirmHandlerTest.java | 135 ++++++ .../handlers/GatewayPaymentHandlerTest.java | 181 ++++++++ .../handlers/IntegrationHandlerTest.java | 176 ++++++++ .../handlers/PollStatusHandlerTest.java | 158 +++++++ .../handlers/RecipientsUpdateHandlerTest.java | 136 ++++++ .../resources/application-test.properties | 24 ++ 26 files changed, 2263 insertions(+), 104 deletions(-) create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileResult.java rename src/main/java/zw/qantra/tm/domain/dtos/{SBZProductDto.java => ProductDto.java} (95%) create mode 100644 src/test/java/zw/qantra/tm/TmApplicationTests.java create mode 100644 src/test/java/zw/qantra/tm/domain/repositories/TransactionRepositoryTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/TransactionServiceTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CalculateChargesHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/ConfirmHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/GatewayPaymentHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/IntegrationHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/PollStatusHandlerTest.java create mode 100644 src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/RecipientsUpdateHandlerTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/Dockerfile b/Dockerfile index 0a1a010..ba5849c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,13 @@ FROM 144.91.121.112:8082/java-17 RUN mkdir /app COPY . /app WORKDIR /app + +# Install fontconfig and fonts for Apache POI XSSFWorkbook font rendering +RUN apt-get update && apt-get install -y --no-install-recommends \ + fontconfig \ + fonts-dejavu \ + && rm -rf /var/lib/apt/lists/* + RUN mvn clean compile -P production package ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD ["java", "-Dspring.profiles.active=nflow.db.postgresql", "-jar", "/app/target/tm-0.0.1-RELEASE.jar"] +CMD ["java", "-Djava.awt.headless=true", "-Dspring.profiles.active=nflow.db.postgresql", "-jar", "/app/target/tm-0.0.1-RELEASE.jar"] diff --git a/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java b/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java index f95c7b5..fe4882a 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/RecipientGroupController.java @@ -8,8 +8,12 @@ import net.kaczmarzyk.spring.data.jpa.web.annotation.Spec; import org.hibernate.query.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest; +import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult; import zw.qantra.tm.domain.dtos.GroupMemberRequest; import zw.qantra.tm.domain.dtos.RecipientGroupCreateRequest; import zw.qantra.tm.domain.dtos.RecipientGroupUpdateRequest; @@ -45,6 +49,24 @@ public class RecipientGroupController { return ResponseEntity.ok(recipientGroupService.create(group, request.getRecipients())); } + @PostMapping(value = "/create-from-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createFromFile( + @RequestParam("file") MultipartFile file, + @RequestParam("groupName") String groupName, + @RequestParam(value = "description", required = false) String description, + @RequestParam(value = "requestedBy", required = false) String requestedBy, + @RequestParam(value = "currency", required = false) String currency, + @RequestParam("userId") String userId) { + + GroupCreateFromFileRequest request = new GroupCreateFromFileRequest(); + request.setGroupName(groupName.trim()); + request.setDescription(description.trim()); + request.setRequestedBy(requestedBy); + request.setCurrency(currency); + + return ResponseEntity.ok(recipientGroupService.createFromFile(file, request, userId)); + } + @PutMapping("/update/{groupId}") public ResponseEntity update(@PathVariable UUID groupId, @RequestBody RecipientGroupUpdateRequest request) { diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java index 0bc21b6..56e04d8 100644 --- a/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupBatchCreateRequest.java @@ -21,4 +21,3 @@ public class GroupBatchCreateRequest { private String paymentProcessorImage; private Transaction transactionTemplate; } - diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileRequest.java new file mode 100644 index 0000000..048f0df --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileRequest.java @@ -0,0 +1,22 @@ +package zw.qantra.tm.domain.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import zw.qantra.tm.domain.models.Transaction; + +import java.math.BigDecimal; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupCreateFromFileRequest { + private String groupName; + private String groupDescription; + private String description; + private String requestedBy; + private String currency; + private BigDecimal amount; + private String paymentProcessorLabel; + private String paymentProcessorName; + private String paymentProcessorImage; + private Transaction transactionTemplate; +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileResult.java b/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileResult.java new file mode 100644 index 0000000..04afb2c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/GroupCreateFromFileResult.java @@ -0,0 +1,29 @@ +package zw.qantra.tm.domain.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; +import zw.qantra.tm.domain.models.GroupBatch; + +import java.util.List; + +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupCreateFromFileResult { + private GroupBatch batch; + private int totalRows; + private int successCount; + private int errorCount; + private List errors; + + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RowError { + private int lineNumber; + private String account; + private String name; + private String message; + } +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/dtos/SBZProductDto.java b/src/main/java/zw/qantra/tm/domain/dtos/ProductDto.java similarity index 95% rename from src/main/java/zw/qantra/tm/domain/dtos/SBZProductDto.java rename to src/main/java/zw/qantra/tm/domain/dtos/ProductDto.java index da8df2d..143cf24 100644 --- a/src/main/java/zw/qantra/tm/domain/dtos/SBZProductDto.java +++ b/src/main/java/zw/qantra/tm/domain/dtos/ProductDto.java @@ -14,7 +14,7 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) -public class SBZProductDto { +public class ProductDto { private String name; private String description; private String displayName; diff --git a/src/main/java/zw/qantra/tm/domain/models/Transaction.java b/src/main/java/zw/qantra/tm/domain/models/Transaction.java index 0aa6164..e90b7ef 100644 --- a/src/main/java/zw/qantra/tm/domain/models/Transaction.java +++ b/src/main/java/zw/qantra/tm/domain/models/Transaction.java @@ -72,6 +72,8 @@ public class Transaction extends BaseEntity { private String creditCard; private String creditRef; private String creditEmail; + private String creditAddress; + private String creditVoucher; private String billClientId; private String clientSecret; private String aggregatorId; diff --git a/src/main/java/zw/qantra/tm/domain/repositories/ProviderRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/ProviderRepository.java index b69b3dd..f1fa23c 100644 --- a/src/main/java/zw/qantra/tm/domain/repositories/ProviderRepository.java +++ b/src/main/java/zw/qantra/tm/domain/repositories/ProviderRepository.java @@ -3,6 +3,7 @@ package zw.qantra.tm.domain.repositories; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; +import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.models.Provider; import java.util.List; @@ -12,5 +13,7 @@ import java.util.UUID; public interface ProviderRepository extends JpaRepository, JpaSpecificationExecutor { List findByCategory(String category); Provider findByClientId(String clientId); + Provider findByLabel(String label); + Provider findByLabelAndCurrency(String label, CurrencyType currency); boolean existsByClientId(String clientId); } \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java b/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java index a739bd6..83fb4cb 100644 --- a/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java +++ b/src/main/java/zw/qantra/tm/domain/services/BatchReportService.java @@ -121,7 +121,7 @@ public class BatchReportService { Cell titleCell = titleRow.createCell(0); titleCell.setCellValue("Batch Report - " + (batch.getDescription() != null ? batch.getDescription() : batch.getId().toString())); titleCell.setCellStyle(titleStyle); - sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 9)); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 11)); titleRow.setHeightInPoints(30); rowNum++; @@ -149,7 +149,8 @@ public class BatchReportService { String[] headers = { "#", "Recipient Name", "Phone", "Amount", "Status", "Confirm Status", "Integration Status", - "Transaction Ref", "Confirm Error", "Integration Error" + "Transaction Ref", "Confirm Error", "Integration Error", + "Credit Address", "Credit Voucher" }; Row headerRow = sheet.createRow(rowNum++); @@ -214,6 +215,22 @@ public class BatchReportService { Cell integrationErrCell = row.createCell(9); integrationErrCell.setCellValue(item.getIntegrationError() != null ? item.getIntegrationError() : ""); integrationErrCell.setCellStyle(dataStyle); + + Cell creditAddressCell = row.createCell(10); + if (item.getTransaction() != null && item.getTransaction().getCreditAddress() != null) { + creditAddressCell.setCellValue(item.getTransaction().getCreditAddress()); + } else { + creditAddressCell.setCellValue(""); + } + creditAddressCell.setCellStyle(dataStyle); + + Cell creditVoucherCell = row.createCell(11); + if (item.getTransaction() != null && item.getTransaction().getCreditVoucher() != null) { + creditVoucherCell.setCellValue(item.getTransaction().getCreditVoucher()); + } else { + creditVoucherCell.setCellValue(""); + } + creditVoucherCell.setCellStyle(dataStyle); } // Auto-size columns diff --git a/src/main/java/zw/qantra/tm/domain/services/JobService.java b/src/main/java/zw/qantra/tm/domain/services/JobService.java index c770c91..47a3d75 100644 --- a/src/main/java/zw/qantra/tm/domain/services/JobService.java +++ b/src/main/java/zw/qantra/tm/domain/services/JobService.java @@ -4,6 +4,7 @@ import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstanceAction; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jobrunr.jobs.context.JobRunrDashboardLogger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,10 +23,10 @@ import java.util.concurrent.CompletableFuture; import static org.joda.time.DateTime.now; +@Slf4j @Component @RequiredArgsConstructor public class JobService { - Logger log = new JobRunrDashboardLogger(LoggerFactory.getLogger(JobService.class)); private final WorkflowInstanceService workflowInstanceService; private final WorkflowUtils workflowUtils; diff --git a/src/main/java/zw/qantra/tm/domain/services/ProviderService.java b/src/main/java/zw/qantra/tm/domain/services/ProviderService.java index e551fd4..3aec49a 100644 --- a/src/main/java/zw/qantra/tm/domain/services/ProviderService.java +++ b/src/main/java/zw/qantra/tm/domain/services/ProviderService.java @@ -6,16 +6,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; - -import zw.qantra.tm.domain.dtos.SBZProductDto; +import zw.qantra.tm.domain.dtos.ProductDto; import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.repositories.ProviderRepository; import zw.qantra.tm.exceptions.ApiException; @@ -35,59 +32,33 @@ public class ProviderService { private final RestService restService; private final HotRechargeTokenService hotRechargeTokenService; + private Map> providerProductsCache = new HashMap<>(); + @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") private String hotApiUrl; @Value("${sbz.aggregator.url}") private String aggregatorUrl; - public SBZProductDto getProviderProduct(UUID providerId, String productName) { - List products = getProviderProducts(providerId.toString()); - SBZProductDto productResult = products.stream() + public ProductDto getProviderProduct(UUID providerId, String productName) { + List products = getProviderProducts(providerId.toString()); + ProductDto productResult = products.stream() .filter(product -> product.getName().equals(productName)) .findFirst() .orElse(null); + log.info("Product: {}", productResult); return productResult; } - @Deprecated - public List getProviderProducts(UUID providerId) { - String token = restService.getMerchantToken(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); - headers.setBearerAuth(token); - - LinkedHashMap response; + public List getProviderProducts(String providerUid) { try { - response = this.restService.getAs( - aggregatorUrl + "/merchant/aggregation/products?size=20&clientUid=" + providerId.toString(), - headers, - LinkedHashMap.class - ); - } catch (Exception e) { - log.error("Error fetching products for provider " + providerId, e); - return Collections.emptyList(); - } + List cachedProducts = providerProductsCache.get(providerUid); + if (cachedProducts != null) { + log.info("Returning cached products for provider: {}, number of products: {}", providerUid, + cachedProducts.size()); + return cachedProducts; + } - LinkedHashMap body = (LinkedHashMap) response.get("body"); - List content = (List) body.get("content"); - - List products = new ArrayList<>(); - for (LinkedHashMap product : content) { - ObjectMapper mapper = new ObjectMapper(); - SBZProductDto productDto = mapper.convertValue(product, SBZProductDto.class); - products.add(productDto); - } - - return products; - } - - // todo: figure out caching for different providers - // @Cacheable(value = "providerProducts", key = "#providerUid") - public List getProviderProducts(String providerUid) { - try { Provider provider = providerRepository.findById(UUID.fromString(providerUid)).orElse(null); if (provider == null) { return Collections.emptyList(); @@ -113,25 +84,28 @@ public class ProviderService { } List stock = (List) response.get("stock"); - List products = new ArrayList<>(); + List products = new ArrayList<>(); for (Object item : stock) { LinkedHashMap stockItem = (LinkedHashMap) item; int stockProviderId = (int) stockItem.get("productId"); // providerId is referred to as productId in the stock response if (String.valueOf(stockProviderId).equals(providerId)) { - log.info("Found matching provider in stock: " + stockItem); - SBZProductDto sbzProductDto = new SBZProductDto(); - sbzProductDto.setName((String) stockItem.get("name")); - sbzProductDto.setDescription((String) stockItem.get("description")); - sbzProductDto.setDisplayName((String) stockItem.get("name")); - sbzProductDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString())); - sbzProductDto.setRequiresAmount(true); // always true for now - sbzProductDto.setUid(UUID.fromString(Utils.getUid())); - sbzProductDto.setExternalId((String) stockItem.get("productCode")); - products.add(sbzProductDto); + ProductDto productDto = new ProductDto(); + productDto.setName((String) stockItem.get("name")); + productDto.setDescription((String) stockItem.get("description")); + productDto.setDisplayName((String) stockItem.get("name")); + productDto.setDefaultAmount(new BigDecimal(stockItem.get("amount").toString())); + productDto.setRequiresAmount(true); // always true for now + productDto.setUid(UUID.fromString(Utils.getUid())); + productDto.setExternalId((String) stockItem.get("productCode")); + products.add(productDto); } } + providerProductsCache.put(providerUid, products); + log.info("Cached products for provider: {}, number of products: {}", providerUid, products.size()); + log.info("Provider products cache size: {}", providerProductsCache.size()); + return products; } catch (IllegalArgumentException e) { log.error("Error fetching products: {}", providerUid); @@ -152,11 +126,11 @@ public class ProviderService { public String getExternalId(String providerId, String productName) { try { - List products = getProviderProducts(providerId); + List products = getProviderProducts(providerId); return products.stream() .filter(product -> product.getName().equals(productName)) - .map(SBZProductDto::getExternalId) + .map(ProductDto::getExternalId) .findFirst() .orElse(null); } catch (Exception e) { diff --git a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java index 8e932bc..17c0515 100644 --- a/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java +++ b/src/main/java/zw/qantra/tm/domain/services/RecipientGroupService.java @@ -6,14 +6,25 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import zw.qantra.tm.domain.models.Recipient; -import zw.qantra.tm.domain.models.RecipientGroup; -import zw.qantra.tm.domain.models.RecipientGroupMember; +import org.springframework.web.multipart.MultipartFile; +import zw.qantra.tm.domain.dtos.GroupCreateFromFileRequest; +import zw.qantra.tm.domain.dtos.GroupCreateFromFileResult; +import zw.qantra.tm.domain.dtos.ProductDto; +import zw.qantra.tm.domain.enums.*; +import zw.qantra.tm.domain.models.*; +import zw.qantra.tm.domain.repositories.GroupBatchItemRepository; +import zw.qantra.tm.domain.repositories.GroupBatchRepository; import zw.qantra.tm.domain.repositories.RecipientGroupMemberRepository; import zw.qantra.tm.domain.repositories.RecipientGroupRepository; import zw.qantra.tm.domain.repositories.RecipientRepository; import zw.qantra.tm.exceptions.ApiException; +import zw.qantra.tm.utils.Utils; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -24,6 +35,347 @@ public class RecipientGroupService { private final RecipientGroupRepository recipientGroupRepository; private final RecipientGroupMemberRepository recipientGroupMemberRepository; private final RecipientRepository recipientRepository; + private final GroupBatchRepository groupBatchRepository; + private final GroupBatchItemRepository groupBatchItemRepository; + private final ProviderService providerService; + + /** + * Creates a recipient group, creates a batch from it, then updates batch items + * with bill/provider fields from the CSV file, and returns the new batch + * along with any per-row errors. + * + * CSV file structure: name, account, phone, billName, billProductName + * + * Steps: + * 1. Parse CSV rows, collecting per-row validation errors + * 2. Create recipient group from all valid rows + * 3. Create group batch from the group + * 4. Update batch items with billName & billProductName from CSV, skipping rows + * where the provider cannot be resolved and recording those errors + * 5. Return the batch + any row-level errors + */ + @Transactional + public GroupCreateFromFileResult createFromFile(MultipartFile file, GroupCreateFromFileRequest request, String userId) { + ParseResult parseResult = parseCsv(file); + + if(!parseResult.parseErrors.isEmpty()) { + return GroupCreateFromFileResult.builder() + .totalRows(parseResult.rows.size() + parseResult.parseErrors.size()) + .errorCount(parseResult.parseErrors.size()) + .errors(parseResult.parseErrors) + .build(); + } + + if (parseResult.rows.isEmpty()) { + throw new ApiException("CSV file has no valid data rows after validation"); + } + + // Step 1: build recipient list from valid CSV rows only + List recipients = new ArrayList<>(); + for (CsvRow row : parseResult.rows) { + Recipient recipient = Recipient.builder() + .name(row.name) + .account(row.account) + .phoneNumber(row.phone) + .userId(userId) + .build(); + recipients.add(recipient); + } + + // Step 2: create recipient group with recipients + RecipientGroup group = RecipientGroup.builder() + .name(request.getGroupName()) + .description(request.getDescription()) + .userId(userId) + .build(); + + RecipientGroup savedGroup = recipientGroupRepository.save(group); + + List members = addMembers(savedGroup.getId(), userId, recipients); + + // Step 3: create group batch (inline to avoid circular dependency with GroupBatchService) + GroupBatch batch = createBatchInline(savedGroup.getId(), userId, request, members); + + // Step 4: update batch items with bill/provider fields from CSV + List batchItems = groupBatchItemRepository.findAllByGroupBatchId(batch.getId()); + + List itemUpdates = new ArrayList<>(); + List rowErrors = new ArrayList<>(parseResult.parseErrors); + + for (int i = 0; i < batchItems.size() && i < parseResult.rows.size(); i++) { + GroupBatchItem batchItem = batchItems.get(i); + CsvRow row = parseResult.rows.get(i); + + // Attempt to resolve provider by label + Provider provider = providerService.getProviderRepository().findByLabelAndCurrency( + row.billLabel, CurrencyType.valueOf(request.getCurrency())); + if (provider == null) { + rowErrors.add(GroupCreateFromFileResult.RowError.builder() + .lineNumber(row.lineNumber) + .account(row.account) + .name(row.name) + .message("Provider not found for bill label: " + row.billLabel) + .build()); + // Skip this row - item remains with default values from batch creation + continue; + } + + itemUpdates.add(new GroupBatchItemUpdate(batchItem.getId(), row, provider)); + } + + // Apply successful item updates directly + if (!itemUpdates.isEmpty()) { + List providerErrors = updateBatchItemsInline(batch.getId(), itemUpdates); + if (!providerErrors.isEmpty()) { + return GroupCreateFromFileResult.builder() + .totalRows(parseResult.rows.size() + parseResult.parseErrors.size()) + .errorCount(providerErrors.size()) + .errors(providerErrors) + .build(); + } + } + + // Return the updated batch + errors + GroupBatch resultBatch = groupBatchRepository.findByIdAndUserId(batch.getId(), userId) + .orElseThrow(() -> new ApiException("Batch not found after creation")); + + return GroupCreateFromFileResult.builder() + .batch(resultBatch) + .totalRows(parseResult.rows.size() + parseResult.parseErrors.size()) + .successCount(itemUpdates.size()) + .errorCount(rowErrors.size()) + .errors(rowErrors) + .build(); + } + + /** + * Create a GroupBatch and its items directly (mirrors GroupBatchService.createBatch logic + * but without circular dependency). + */ + private GroupBatch createBatchInline(UUID recipientGroupId, String userId, + GroupCreateFromFileRequest request, + List members) { + GroupBatch batch = GroupBatch.builder() + .recipientGroupId(recipientGroupId) + .userId(userId) + .description(request.getDescription()) + .requestedBy(request.getRequestedBy()) + .currency(request.getCurrency()) + .status(GroupBatchStatus.CREATED) + .transactionTemplate(Utils.toJson(request.getTransactionTemplate())) + .build(); + + GroupBatch savedBatch = groupBatchRepository.save(batch); + + int itemCount = 0; + + for (RecipientGroupMember member : members) { + Recipient recipient = recipientRepository.findById(member.getRecipientUid()) + .orElseThrow(() -> new ApiException("Recipient not found: " + member.getRecipientUid())); + + GroupBatchItem item = GroupBatchItem.builder() + .groupBatchId(savedBatch.getId()) + .recipientId(recipient.getId()) + .recipientName(recipient.getName()) + .recipientPhone(recipient.getPhoneNumber()) + .status(GroupBatchItemStatus.NEW) + .confirmStatus(GroupBatchItemConfirmStatus.PENDING) + .integrationStatus(GroupBatchItemIntegrationStatus.PENDING) + .build(); + + itemCount++; + groupBatchItemRepository.save(item); + } + + savedBatch.setCount(itemCount); + savedBatch.setValue(BigDecimal.ZERO); + savedBatch.setSuccessfulCount(0); + savedBatch.setFailedCount(0); + return groupBatchRepository.save(savedBatch); + } + + /** + * Update batch items with CSV data directly (mirrors GroupBatchService.updateBatchItems logic + * but without circular dependency). + */ + private List updateBatchItemsInline(UUID batchId, List updates) { + List existingItems = groupBatchItemRepository.findAllByGroupBatchId(batchId); + java.util.Map itemMap = new java.util.HashMap<>(); + for (GroupBatchItem item : existingItems) { + itemMap.put(item.getId(), item); + } + + List updatedItems = new ArrayList<>(); + + List providerErrors = new ArrayList<>(); + for (GroupBatchItemUpdate update : updates) { + GroupBatchItem item = itemMap.get(update.batchItemId); + if (item == null) { + continue; + } + + try { + item.setRecipientName(update.row.name); + item.setRecipientPhone(update.row.phone); + item.setBillName(update.provider.getName()); + item.setBillProductName(update.row.billProductName); + item.setProviderLabel(update.row.billLabel); + item.setProviderImage(update.provider.getImage()); + item.setBillClientId(update.provider.getClientId()); + + if(update.row.billProductName.isBlank() && update.row.amount.compareTo(BigDecimal.ZERO) != 0) { + item.setAmount(update.row.amount); + } else if (!update.row.billProductName.isBlank()) { + ProductDto productDto = providerService.getProviderProduct( + update.provider.getId(), update.row.billProductName); + if( productDto == null) { + throw new ApiException("Product not found for provider: " + update.provider.getName() + + ", product name: " + update.row.billProductName); + } + item.setAmount(productDto.getDefaultAmount()); + } + updatedItems.add(groupBatchItemRepository.save(item)); + } catch (Exception e) { + providerErrors.add(GroupCreateFromFileResult.RowError.builder() + .lineNumber(update.row.lineNumber) + .account(update.row.account) + .name(update.row.name) + .message("Failed to update batch item: " + e.getMessage()) + .build()); + } + } + + // Recalculate batch totals + if (!updatedItems.isEmpty()) { + List allItems = groupBatchItemRepository.findAllByGroupBatchId(batchId); + BigDecimal newTotalValue = allItems.stream() + .map(GroupBatchItem::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + GroupBatch batch = groupBatchRepository.findById(batchId) + .orElseThrow(() -> new ApiException("Batch not found")); + batch.setValue(newTotalValue); + batch.setCount(allItems.size()); + groupBatchRepository.save(batch); + } + + return providerErrors; + } + + private record GroupBatchItemUpdate(UUID batchItemId, CsvRow row, Provider provider) {} + + /** + * Parse CSV rows, collecting validation errors per row rather than failing fast. + */ + private ParseResult parseCsv(MultipartFile file) { + List rows = new ArrayList<>(); + List parseErrors = new ArrayList<>(); + + try (Reader reader = new InputStreamReader(file.getInputStream()); + BufferedReader br = new BufferedReader(reader)) { + + String headerLine = br.readLine(); // skip header + if (headerLine == null) { + throw new ApiException("CSV file is empty"); + } + + String line; + int lineNum = 1; + while ((line = br.readLine()) != null) { + lineNum++; + if (line.isBlank()) { + continue; + } + String[] parts = parseCsvLine(line); + if (parts.length < 5) { + parseErrors.add(GroupCreateFromFileResult.RowError.builder() + .lineNumber(lineNum) + .message("Invalid CSV format: expected at least 5 columns (name, account, phone, billName, billProductName) but got " + parts.length) + .build()); + continue; + } + + String name = parts[0].trim(); + String account = parts[1].trim(); + String phone = parts[2].trim(); + String billLabel = parts[3].trim(); + String billProductName = parts[4].trim(); + String amount = parts[5].trim(); + + if (name.isEmpty() || account.isEmpty()) { + parseErrors.add(GroupCreateFromFileResult.RowError.builder() + .lineNumber(lineNum) + .account(account) + .name(name) + .message("name and account are required") + .build()); + continue; + } + + if(amount.isEmpty() && billProductName.isEmpty()) { + parseErrors.add(GroupCreateFromFileResult.RowError.builder() + .lineNumber(lineNum) + .account(account) + .name(name) + .message("amount or billProductName is required") + .build()); + continue; + } + + CsvRow row = new CsvRow(); + row.lineNumber = lineNum; + row.name = name; + row.account = account; + row.phone = phone; + row.billLabel = billLabel; + row.billProductName = billProductName; + row.amount = !amount.isEmpty() ? new BigDecimal(amount) : BigDecimal.ZERO; + rows.add(row); + } + + if (rows.isEmpty() && parseErrors.isEmpty()) { + throw new ApiException("CSV file has no data rows"); + } + + } catch (IOException e) { + throw new ApiException("Failed to read CSV file: " + e.getMessage()); + } + return new ParseResult(rows, parseErrors); + } + + /** + * Parse a CSV line respecting simple double-quoted fields. + */ + private String[] parseCsvLine(String line) { + List fields = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '"') { + inQuotes = !inQuotes; + } else if (c == ',' && !inQuotes) { + fields.add(current.toString()); + current = new StringBuilder(); + } else { + current.append(c); + } + } + fields.add(current.toString()); + return fields.toArray(new String[0]); + } + + private static class CsvRow { + int lineNumber; + String name; + String account; + String phone; + String billLabel; + String billProductName; + BigDecimal amount; + } + + private record ParseResult(List rows, List parseErrors) {} @Transactional public RecipientGroup create(RecipientGroup group, List recipients) { @@ -43,10 +395,6 @@ public class RecipientGroupService { for (Recipient incomingRecipient : recipients) { Recipient recipient = resolveOrCreateRecipient(incomingRecipient, userId); - if (recipientGroupMemberRepository.existsByRecipientGroupIdAndRecipientId(groupId, recipient.getId())) { - continue; - } - RecipientGroupMember member = RecipientGroupMember.builder() .recipientGroupId(groupId) .recipientUid(recipient.getId()) @@ -154,5 +502,4 @@ public class RecipientGroupService { public Page findAllMembers(Specification spec, Pageable pageable) { return recipientGroupMemberRepository.findAll(spec, pageable); } -} - +} \ No newline at end of file diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java index e8f6bb2..d5c17a4 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/confirmations/hotrecharge/ZesaConfirmationHotProcessor.java @@ -104,6 +104,17 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa .build(); additionalDataService.getAdditionalDataRepository().save(additionalData); + // update customer address in creditAddress field + Map details = (Map) customerData.get("details"); + if (details != null) { + for (Map.Entry entry : details.entrySet()) { + if (entry.getKey().equalsIgnoreCase("AccountName")) { + transaction.setCreditAddress(entry.getValue().toString()); + break; + } + } + } + } catch (Exception e) { logger.error("Error processing ZESA confirmation: " + e.getMessage(), e); transaction.setStatus(Status.FAILED); @@ -122,7 +133,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa Map details = (Map) customerData.get("details"); if (details != null) { for (Map.Entry entry : details.entrySet()) { - // don't show customer ZWG value. It leads to confusion + // don't show customer ZWG value. It causes confusion if(entry.getValue().toString().equals("ZWG")) continue; additionalDataList.add(Map.of("name", entry.getKey(), "value", entry.getValue().toString())); diff --git a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java index 1dd99fe..e1827ec 100644 --- a/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java +++ b/src/main/java/zw/qantra/tm/domain/services/processors/integrations/HotRechargeIntegrationProcessor.java @@ -105,6 +105,18 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte .jsonString(Utils.toJson(additionalDataList)) .build(); additionalDataService.getAdditionalDataRepository().save(additionalData); + + // if provider is zesa, update customer token in creditVoucher field + String providerId = providerService.getProviderId(transaction.getBillClientId()); + if(providerId.equals("41")) { + for (Map.Entry entry : rechargeData.entrySet()) { + if (entry.getKey().equalsIgnoreCase("Token")) { + transaction.setCreditVoucher(entry.getValue().toString()); + break; + } + } + } + } } else { transaction.setStatus(Status.FAILED); @@ -138,19 +150,15 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte request.put("ProductId", providerId); request.put("Target", transaction.getCreditAccount()); request.put("Amount", transaction.getCreditAmount()); - - // 41 = zesa - if(providerId.equals("41")) { - request.put("CustomerSMS", "A ZETDC token was purchased for " + - "%ACOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units."); - } - else { - request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%."); - } + request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%."); // do this for zesa only if(providerId.equals("41")) { - // update product to zwg product id + // override sms for zesa + request.put("CustomerSMS", "A ZETDC token was purchased for " + + "%ACCOUNTNAME% (%METERNUMBER%) that resulted in %KWH% units."); + + // if necessary, update product to zwg product id if(transaction.getCreditCurrency().equals(CurrencyType.ZWG)) { String zwgProviderId = providerService.getProviderId(ZESA_ZWG_CLIENT); request.put("ProductId", zwgProviderId); @@ -169,7 +177,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte } } - // do this for Econet bundles + // do this for providers with bundles Provider provider = providerService.getProviderRepository() .findByClientId(transaction.getBillClientId()); if(provider.getRequiresProducts().equals(true)) { diff --git a/src/main/resources/liquibase-diff-changeLog.xml b/src/main/resources/liquibase-diff-changeLog.xml index 069618f..e4a2b69 100644 --- a/src/main/resources/liquibase-diff-changeLog.xml +++ b/src/main/resources/liquibase-diff-changeLog.xml @@ -1,28 +1,13 @@ - - - + + + - - - - - - - - - - - - - - - - - - + + + diff --git a/src/test/java/zw/qantra/tm/TmApplicationTests.java b/src/test/java/zw/qantra/tm/TmApplicationTests.java new file mode 100644 index 0000000..674e2d1 --- /dev/null +++ b/src/test/java/zw/qantra/tm/TmApplicationTests.java @@ -0,0 +1,14 @@ +package zw.qantra.tm; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class TmApplicationTests { + + @Test + void contextLoads() { + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/repositories/TransactionRepositoryTest.java b/src/test/java/zw/qantra/tm/domain/repositories/TransactionRepositoryTest.java new file mode 100644 index 0000000..98cfa5d --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/repositories/TransactionRepositoryTest.java @@ -0,0 +1,155 @@ +package zw.qantra.tm.domain.repositories; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; +import zw.qantra.tm.domain.enums.CurrencyType; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Transaction; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ActiveProfiles("test") +class TransactionRepositoryTest { + + @Autowired + private TransactionRepository transactionRepository; + + @Test + @DisplayName("Should save and find transaction by ID") + void shouldSaveAndFindById() { + Transaction transaction = createTestTransaction(); + Transaction saved = transactionRepository.save(transaction); + + Optional found = transactionRepository.findById(saved.getId()); + + assertTrue(found.isPresent()); + assertEquals("user-1", found.get().getUserId()); + assertEquals(new BigDecimal("100.00"), found.get().getAmount()); + } + + @Test + @DisplayName("Should return empty when transaction not found") + void shouldReturnEmptyWhenNotFound() { + Optional found = transactionRepository.findById(UUID.randomUUID()); + + assertTrue(found.isEmpty()); + } + + @Test + @DisplayName("Should find transactions by userId") + void shouldFindByUserId() { + Transaction tx1 = createTestTransaction(); + tx1.setUserId("user-1"); + Transaction tx2 = createTestTransaction(); + tx2.setUserId("user-2"); + transactionRepository.save(tx1); + transactionRepository.save(tx2); + + var results = transactionRepository.findAllByUserId("user-1"); + + assertEquals(1, results.size()); + assertEquals("user-1", results.get(0).getUserId()); + } + + @Test + @DisplayName("Should return paginated results") + void shouldReturnPaginatedResults() { + for (int i = 0; i < 5; i++) { + Transaction tx = createTestTransaction(); + tx.setUserId("user-" + i); + transactionRepository.save(tx); + } + + Pageable pageable = PageRequest.of(0, 2); + Page page = transactionRepository.findAll(pageable); + + assertEquals(5, page.getTotalElements()); + assertEquals(2, page.getContent().size()); + assertEquals(0, page.getNumber()); + } + + @Test + @DisplayName("Should exclude soft-deleted transactions") + void shouldExcludeSoftDeleted() { + Transaction transaction = createTestTransaction(); + Transaction saved = transactionRepository.save(transaction); + + // Hard delete from the test perspective + transactionRepository.deleteById(saved.getId()); + + Optional found = transactionRepository.findById(saved.getId()); + assertTrue(found.isEmpty()); + } + + @Test + @DisplayName("Should persist all transaction fields correctly") + void shouldPersistAllFields() { + Transaction transaction = Transaction.builder() + .userId("user-1") + .trace("TRACE-123") + .region("ZW") + .amount(new BigDecimal("150.00")) + .charge(new BigDecimal("5.00")) + .gatewayCharge(new BigDecimal("2.00")) + .tax(new BigDecimal("1.50")) + .totalAmount(new BigDecimal("158.50")) + .reference("REF-001") + .providerUid("PROV-UID-1") + .productUid("PROD-UID-1") + .paymentProcessorLabel("ecocash") + .integrationProcessorLabel("hotrecharge") + .confirmationProcessorLabel("CONFIRM_econet_hotrecharge") + .providerLabel("econet") + .rrn("RRN-12345") + .debitPhone("+263771234567") + .debitAccount("ACC-001") + .debitCurrency(CurrencyType.USD) + .debitName("John Doe") + .creditPhone("+263778901234") + .creditAccount("ACC-002") + .creditCurrency(CurrencyType.ZWL) + .creditAmount(new BigDecimal("2250.0000")) + .creditName("Jane Doe") + .billClientId("BILL-001") + .confirmationStatus(Status.SUCCESS) + .paymentStatus(Status.SUCCESS) + .integrationStatus(Status.PENDING) + .pollingStatus(Status.PENDING) + .status(Status.SUCCESS) + .build(); + + Transaction saved = transactionRepository.save(transaction); + + Optional found = transactionRepository.findById(saved.getId()); + assertTrue(found.isPresent()); + assertEquals("user-1", found.get().getUserId()); + assertEquals(new BigDecimal("150.00"), found.get().getAmount()); + assertEquals(new BigDecimal("5.00"), found.get().getCharge()); + assertEquals(new BigDecimal("158.50"), found.get().getTotalAmount()); + assertEquals(Status.SUCCESS, found.get().getConfirmationStatus()); + assertEquals(Status.SUCCESS, found.get().getPaymentStatus()); + assertEquals(Status.PENDING, found.get().getIntegrationStatus()); + assertEquals("+263771234567", found.get().getDebitPhone()); + assertEquals("+263778901234", found.get().getCreditPhone()); + } + + private Transaction createTestTransaction() { + return Transaction.builder() + .userId("user-1") + .amount(new BigDecimal("100.00")) + .debitCurrency(CurrencyType.USD) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/TransactionServiceTest.java b/src/test/java/zw/qantra/tm/domain/services/TransactionServiceTest.java new file mode 100644 index 0000000..c1f36b6 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/TransactionServiceTest.java @@ -0,0 +1,167 @@ +package zw.qantra.tm.domain.services; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import zw.qantra.tm.domain.enums.CurrencyType; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.models.AdditionalData; +import zw.qantra.tm.domain.models.Currency; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.repositories.AdditionalDataRepository; +import zw.qantra.tm.domain.repositories.TransactionRepository; +import zw.qantra.tm.exceptions.ApiException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TransactionServiceTest { + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private AdditionalDataService additionalDataService; + + @Mock + private CurrencyService currencyService; + + @Mock + private AdditionalDataRepository additionalDataRepository; + + @InjectMocks + private TransactionService transactionService; + + private Transaction transaction; + private final UUID txId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setId(txId); + transaction.setUserId("user-1"); + transaction.setAmount(new BigDecimal("100.00")); + transaction.setDebitCurrency(CurrencyType.USD); + transaction.setCreditCurrency(CurrencyType.ZWL); + + lenient().when(additionalDataService.getAdditionalDataRepository()).thenReturn(additionalDataRepository); + } + + @Test + @DisplayName("Should find transaction by ID") + void shouldFindById() { + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + AdditionalData additionalData = new AdditionalData(); + additionalData.setJsonString("[{\"key\":\"value\"}]"); + when(additionalDataRepository.findByTransactionIdAndRequestType( + txId.toString(), RequestType.INTEGRATION)) + .thenReturn(Optional.of(additionalData)); + + Transaction result = transactionService.findById(txId); + + assertNotNull(result); + assertEquals(txId, result.getId()); + assertNotNull(result.getAdditionalData()); + } + + @Test + @DisplayName("Should throw exception when transaction not found by ID") + void shouldThrowWhenNotFound() { + when(transactionRepository.findById(txId)).thenReturn(Optional.empty()); + + assertThrows(ApiException.class, () -> transactionService.findById(txId)); + } + + @Test + @DisplayName("Should find transaction without additional data gracefully") + void shouldFindByIdWithoutAdditionalData() { + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + when(additionalDataRepository.findByTransactionIdAndRequestType( + txId.toString(), RequestType.INTEGRATION)) + .thenThrow(new ApiException("Not found")); + + Transaction result = transactionService.findById(txId); + + assertNotNull(result); + assertNull(result.getAdditionalData()); + } + + @Test + @DisplayName("Should return paginated transactions") + void shouldFindAllPaginated() { + Specification spec = mock(Specification.class); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(transaction)); + + when(transactionRepository.findAll(spec, pageable)).thenReturn(page); + + Page result = transactionService.findAll(spec, pageable); + + assertEquals(1, result.getTotalElements()); + assertEquals(txId, result.getContent().get(0).getId()); + } + + @Test + @DisplayName("Should calculate credit amount using bank rate") + void shouldCalculateCreditAmount() { + Currency currency = new Currency(); + currency.setBankRate("15.0"); + + when(currencyService.findCurrencyByIsoCode("ZWL")).thenReturn(currency); + + BigDecimal creditAmount = transactionService.calculateCreditAmount(transaction); + + assertEquals(new BigDecimal("1500.0000"), creditAmount); + assertEquals(new BigDecimal("1500.0000"), transaction.getCreditAmount()); + } + + @Test + @DisplayName("Should return zero credit amount when credit currency is null") + void shouldReturnZeroWhenCreditCurrencyNull() { + transaction.setCreditCurrency(null); + + BigDecimal result = transactionService.calculateCreditAmount(transaction); + + assertEquals(BigDecimal.ZERO, result); + } + + @Test + @DisplayName("Should reassign transactions from old user to new user") + void shouldReassignTransactions() { + List transactions = List.of(transaction); + when(transactionRepository.findAllByUserId("old-user")).thenReturn(transactions); + when(transactionRepository.save(any(Transaction.class))).thenReturn(transaction); + + transactionService.reassignTransactions("old-user", "new-user"); + + assertEquals("new-user", transaction.getUserId()); + verify(transactionRepository).save(transaction); + } + + @Test + @DisplayName("Should save transaction") + void shouldSave() { + when(transactionRepository.save(transaction)).thenReturn(transaction); + + Transaction result = transactionService.save(transaction); + + assertNotNull(result); + verify(transactionRepository).save(transaction); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CalculateChargesHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CalculateChargesHandlerTest.java new file mode 100644 index 0000000..1e5c5c3 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CalculateChargesHandlerTest.java @@ -0,0 +1,401 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.ChargeConditionType; +import zw.qantra.tm.domain.enums.ChargeLabelEnum; +import zw.qantra.tm.domain.enums.CurrencyType; +import zw.qantra.tm.domain.models.Charge; +import zw.qantra.tm.domain.models.ChargeCondition; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.services.ChargeService; +import zw.qantra.tm.domain.repositories.ChargeRepository; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CalculateChargesHandlerTest { + + @Mock + private ChargeService chargeService; + + @Mock + private ChargeRepository chargeRepository; + + @InjectMocks + private CalculateChargesHandler handler; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = Transaction.builder() + .amount(new BigDecimal("100.00")) + .debitCurrency(CurrencyType.USD) + .paymentProcessorLabel("ecocash") + .integrationProcessorLabel("hotrecharge") + .build(); + + lenient().when(chargeService.getChargeRepository()).thenReturn(chargeRepository); + } + + @Test + @DisplayName("Should apply flat fee charge when no percentage rate is set") + void shouldApplyFlatFeeCharge() { + Charge flatCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("2.50")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(flatCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("2.50"), result.getCharge()); + assertEquals(BigDecimal.ZERO, result.getTax()); + assertEquals(BigDecimal.ZERO, result.getGatewayCharge()); + assertEquals(new BigDecimal("102.50"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should apply percentage charge correctly") + void shouldApplyPercentageCharge() { + Charge percentageCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .percentageRate(new BigDecimal("3.5")) + .flat(null) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(percentageCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("3.50"), result.getCharge()); + assertEquals(new BigDecimal("103.50"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should apply composite charge (percentage + flat)") + void shouldApplyCompositeCharge() { + Charge compositeCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .percentageRate(new BigDecimal("2.0")) + .flat(new BigDecimal("1.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .composite(true) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(compositeCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("3.00"), result.getCharge()); + assertEquals(new BigDecimal("103.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should clamp charge to minimum when calculated below min") + void shouldClampToMinimumCharge() { + Charge minCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .percentageRate(new BigDecimal("0.5")) + .flat(null) + .minimumAmount(null) + .maximumAmount(null) + .min(new BigDecimal("5.00")) + .max(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(minCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("5.00"), result.getCharge()); + assertEquals(new BigDecimal("105.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should clamp charge to maximum when calculated above max") + void shouldClampToMaximumCharge() { + Charge maxCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .percentageRate(new BigDecimal("10.0")) + .flat(null) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(new BigDecimal("8.00")) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(maxCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("8.00"), result.getCharge()); + assertEquals(new BigDecimal("108.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should use minimum amount threshold to trigger min charge") + void shouldUseMinimumAmountThreshold() { + Charge chargeWithMinAmount = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("10.00")) + .minimumAmount(new BigDecimal("50.00")) + .maximumAmount(null) + .min(new BigDecimal("2.00")) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + Transaction smallTx = Transaction.builder() + .amount(new BigDecimal("30.00")) + .debitCurrency(CurrencyType.USD) + .paymentProcessorLabel("ecocash") + .integrationProcessorLabel("hotrecharge") + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(chargeWithMinAmount)); + + Transaction result = handler.process(smallTx); + + assertNotNull(result); + assertEquals(new BigDecimal("2.00"), result.getCharge()); + } + + @Test + @DisplayName("Should use maximum amount threshold to trigger max charge") + void shouldUseMaximumAmountThreshold() { + Charge chargeWithMaxAmount = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("10.00")) + .minimumAmount(null) + .maximumAmount(new BigDecimal("200.00")) + .min(null) + .max(new BigDecimal("15.00")) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + Transaction largeTx = Transaction.builder() + .amount(new BigDecimal("500.00")) + .debitCurrency(CurrencyType.USD) + .paymentProcessorLabel("ecocash") + .integrationProcessorLabel("hotrecharge") + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(chargeWithMaxAmount)); + + Transaction result = handler.process(largeTx); + + assertNotNull(result); + assertEquals(new BigDecimal("15.00"), result.getCharge()); + } + + @Test + @DisplayName("Should skip charge when gateway does not match includes condition") + void shouldSkipChargeWhenGatewayDoesNotMatch() { + Charge charge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("5.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "mpgs"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(charge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(BigDecimal.ZERO, result.getCharge()); + assertEquals(new BigDecimal("100.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should skip charge when gateway is in excludes condition") + void shouldSkipChargeWhenGatewayIsExcluded() { + Charge charge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("5.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(charge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(BigDecimal.ZERO, result.getCharge()); + assertEquals(new BigDecimal("100.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should apply TAX and GATEWAY_FEE charges separately") + void shouldApplyMultipleChargeTypes() { + Charge fee = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("2.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + Charge tax = Charge.builder() + .chargeLabel(ChargeLabelEnum.TAX) + .flat(new BigDecimal("1.50")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + Charge gatewayFee = Charge.builder() + .chargeLabel(ChargeLabelEnum.GATEWAY_FEE) + .flat(new BigDecimal("3.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.GATEWAY, "ecocash"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(fee, tax, gatewayFee)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("2.00"), result.getCharge()); + assertEquals(new BigDecimal("1.50"), result.getTax()); + assertEquals(new BigDecimal("3.00"), result.getGatewayCharge()); + assertEquals(new BigDecimal("106.50"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should handle empty charge list gracefully") + void shouldHandleEmptyChargeList() { + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of()); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(BigDecimal.ZERO, result.getCharge()); + assertEquals(BigDecimal.ZERO, result.getTax()); + assertEquals(BigDecimal.ZERO, result.getGatewayCharge()); + assertEquals(new BigDecimal("100.00"), result.getTotalAmount()); + } + + @Test + @DisplayName("Should match charge by provider condition") + void shouldMatchChargeByProviderCondition() { + Charge providerCharge = Charge.builder() + .chargeLabel(ChargeLabelEnum.FEE) + .flat(new BigDecimal("4.00")) + .minimumAmount(null) + .maximumAmount(null) + .min(null) + .max(null) + .percentageRate(null) + .composite(false) + .currency(CurrencyType.USD) + .includes(Set.of(createCondition(ChargeConditionType.PROVIDER, "hotrecharge"))) + .excludes(Set.of()) + .build(); + + when(chargeRepository.findAllByCurrency(CurrencyType.USD)).thenReturn(List.of(providerCharge)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals(new BigDecimal("4.00"), result.getCharge()); + } + + private ChargeCondition createCondition(ChargeConditionType type, String code) { + ChargeCondition condition = new ChargeCondition(); + condition.setType(type); + condition.setCodes(code); + return condition; + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandlerTest.java new file mode 100644 index 0000000..b0caa52 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/CleanupHandlerTest.java @@ -0,0 +1,185 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.PartyType; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Provider; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.services.ProviderService; +import zw.qantra.tm.domain.repositories.ProviderRepository; +import zw.qantra.tm.exceptions.ApiException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CleanupHandlerTest { + + @Mock + private ProviderService providerService; + + @Mock + private ProviderRepository providerRepository; + + @InjectMocks + private CleanupHandler handler; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setType(RequestType.CONFIRM); + transaction.setBillClientId("client-123"); + transaction.setDebitPhone("+263771234567"); + transaction.setCreditPhone("+263778901234"); + transaction.setCreditAccount("ACC-123"); + transaction.setRegion("ZW"); + + lenient().when(providerService.getProviderRepository()).thenReturn(providerRepository); + } + + @Test + @DisplayName("Should set trace, reference, and initial statuses") + void shouldSetInitialFields() throws Exception { + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result.getTrace()); + assertNotNull(result.getReference()); + assertEquals(Status.PENDING, result.getConfirmationStatus()); + assertEquals(Status.PENDING, result.getPaymentStatus()); + assertEquals(Status.PENDING, result.getIntegrationStatus()); + } + + @Test + @DisplayName("Should set default party type to INDIVIDUAL when null") + void shouldSetDefaultPartyType() throws Exception { + transaction.setPartyType(null); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertEquals(PartyType.INDIVIDUAL, result.getPartyType()); + } + + @Test + @DisplayName("Should preserve existing party type when set") + void shouldPreserveExistingPartyType() throws Exception { + transaction.setPartyType(PartyType.GROUP); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertEquals(PartyType.GROUP, result.getPartyType()); + } + + @Test + @DisplayName("Should set confirmation processor label for CONFIRM type") + void shouldSetConfirmationProcessorLabel() throws Exception { + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertEquals("CONFIRM_econet_hotrecharge", result.getConfirmationProcessorLabel()); + assertEquals("econet", result.getProviderLabel()); + } + + @Test + @DisplayName("Should throw exception when provider not found for billClientId") + void shouldThrowWhenProviderNotFound() { + when(providerRepository.findByClientId("client-123")).thenReturn(null); + + assertThrows(ApiException.class, () -> handler.process(transaction)); + } + + @Test + @DisplayName("Should strip non-hex characters from credit account before phone formatting") + void shouldStripCreditAccount() throws Exception { + transaction.setCreditAccount("ACC-123!@#"); + // Set creditPhone to null so it doesn't overwrite creditAccount + transaction.setCreditPhone(null); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertEquals("ACC123", result.getCreditAccount()); + } + + @Test + @DisplayName("Should set credit account to null when stripped result is empty and no credit phone") + void shouldSetCreditAccountNullWhenEmpty() throws Exception { + transaction.setCreditAccount("!@#$%"); + transaction.setCreditPhone(null); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertNull(result.getCreditAccount()); + } + + @Test + @DisplayName("Should format debit phone number") + void shouldFormatDebitPhone() throws Exception { + transaction.setDebitPhone("0771234567"); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result.getDebitPhone()); + } + + @Test + @DisplayName("Should handle null debit phone gracefully") + void shouldHandleNullDebitPhone() throws Exception { + transaction.setDebitPhone(null); + Provider provider = new Provider(); + provider.setLabel("econet"); + provider.setIntegrationProcessorLabel("hotrecharge"); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + + Transaction result = (Transaction) handler.process(transaction); + + assertNull(result.getDebitPhone()); + } + + @Test + @DisplayName("Should not set confirmation label for non-CONFIRM types") + void shouldSkipConfirmationLabelForNonConfirm() throws Exception { + transaction.setType(RequestType.REQUEST); + + Transaction result = (Transaction) handler.process(transaction); + + assertNull(result.getConfirmationProcessorLabel()); + assertNull(result.getProviderLabel()); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/ConfirmHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/ConfirmHandlerTest.java new file mode 100644 index 0000000..a8127a6 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/ConfirmHandlerTest.java @@ -0,0 +1,135 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.models.TransactionEvent; +import zw.qantra.tm.domain.services.*; +import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; +import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ConfirmHandlerTest { + + @Mock + private TransactionEventService transactionEventService; + + @Mock + private PaymentProcessorFactory paymentProcessorFactory; + + @Mock + private TransactionService transactionService; + + @Mock + private AdditionalDataService additionalDataService; + + @Mock + private TransactionProcessorInterface transactionProcessor; + + @InjectMocks + private ConfirmHandler handler; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setId(UUID.randomUUID()); + transaction.setUserId("user-1"); + transaction.setType(RequestType.CONFIRM); + transaction.setConfirmationProcessorLabel("CONFIRM_econet_hotrecharge"); + transaction.setAmount(new BigDecimal("100.00")); + } + + @Test + @DisplayName("Should process confirmation successfully") + void shouldProcessConfirmationSuccessfully() throws Exception { + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("CONFIRM_econet_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getConfirmationStatus()); + verify(transactionService, times(2)).save(any(Transaction.class)); + verify(transactionEventService, times(2)).save(any(TransactionEvent.class)); + } + + @Test + @DisplayName("Should set failed status when processor throws exception") + void shouldSetFailedStatusOnProcessorError() throws Exception { + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("CONFIRM_econet_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))) + .thenThrow(new RuntimeException("Provider unavailable")); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.FAILED, result.getStatus()); + assertEquals(Status.FAILED, result.getConfirmationStatus()); + assertEquals(Status.FAILED, result.getPaymentStatus()); + assertEquals(Status.FAILED, result.getIntegrationStatus()); + assertEquals("01", result.getResponseCode()); + assertEquals("Provider unavailable", result.getErrorMessage()); + } + + @Test + @DisplayName("Should create transaction event with PROCESSING status initially") + void shouldCreateProcessingEvent() throws Exception { + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("CONFIRM_econet_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + handler.process(transaction); + + verify(transactionEventService, times(2)).save(any(TransactionEvent.class)); + } + + @Test + @DisplayName("Should save transaction before and after processing") + void shouldSaveTransactionBeforeAndAfter() throws Exception { + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("CONFIRM_econet_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + handler.process(transaction); + + verify(transactionService, times(2)).save(any(Transaction.class)); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/GatewayPaymentHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/GatewayPaymentHandlerTest.java new file mode 100644 index 0000000..0d92b27 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/GatewayPaymentHandlerTest.java @@ -0,0 +1,181 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.AuthType; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.models.TransactionEvent; +import zw.qantra.tm.domain.repositories.TransactionRepository; +import zw.qantra.tm.domain.services.*; +import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; +import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; +import zw.qantra.tm.exceptions.ApiException; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GatewayPaymentHandlerTest { + + @Mock + private PaymentProcessorFactory paymentProcessorFactory; + + @Mock + private ChargeService chargeService; + + @Mock + private RecipientService recipientService; + + @Mock + private TransactionEventService transactionEventService; + + @Mock + private TransactionService transactionService; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private TransactionProcessorInterface transactionProcessor; + + @InjectMocks + private GatewayPaymentHandler handler; + + private Transaction transaction; + private final UUID txId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setId(txId); + transaction.setUserId("user-1"); + transaction.setType(RequestType.REQUEST); + transaction.setAuthType(AuthType.MOBILE); + transaction.setPaymentProcessorLabel("ecocash"); + transaction.setPaymentStatus(null); + + lenient().when(transactionService.getTransactionRepository()).thenReturn(transactionRepository); + } + + @Test + @DisplayName("Should initiate gateway payment successfully") + void shouldInitiateGatewayPaymentSuccessfully() throws Exception { + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getPaymentStatus()); + assertEquals(Status.PENDING, result.getPollingStatus()); + } + + @Test + @DisplayName("Should throw exception when transaction is null") + void shouldThrowWhenTransactionIsNull() { + assertThrows(ApiException.class, () -> handler.process(null)); + } + + @Test + @DisplayName("Should fetch transaction from DB when ID is present") + void shouldFetchTransactionFromDb() throws Exception { + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + handler.process(transaction); + + verify(transactionRepository).findById(txId); + } + + @Test + @DisplayName("Should return existing transaction when payment already successful") + void shouldReturnExistingWhenAlreadySuccess() throws Exception { + transaction.setPaymentStatus(Status.SUCCESS); + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getPaymentStatus()); + verify(transactionRepository).findById(txId); + verify(transactionProcessor, never()).process(any()); + } + + @Test + @DisplayName("Should set failed status when processor throws exception") + void shouldSetFailedStatusOnProcessorError() throws Exception { + when(transactionRepository.findById(txId)).thenReturn(Optional.of(transaction)); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))) + .thenThrow(new RuntimeException("Gateway timeout")); + + Transaction result = (Transaction) handler.process(transaction); + + assertEquals(Status.FAILED, result.getStatus()); + assertEquals(Status.FAILED, result.getPaymentStatus()); + assertEquals(Status.FAILED, result.getIntegrationStatus()); + assertEquals("01", result.getResponseCode()); + assertEquals("Gateway timeout", result.getErrorMessage()); + } + + @Test + @DisplayName("Should use incoming transaction type and authType") + void shouldUseIncomingTypeAndAuthType() throws Exception { + Transaction incoming = new Transaction(); + incoming.setId(txId); + incoming.setType(RequestType.REQUEST); + incoming.setAuthType(AuthType.WEB); + incoming.setPaymentProcessorLabel("ecocash"); + + Transaction existing = new Transaction(); + existing.setId(txId); + existing.setUserId("user-1"); + existing.setType(RequestType.CONFIRM); + existing.setPaymentProcessorLabel("ecocash"); + + when(transactionRepository.findById(txId)).thenReturn(Optional.of(existing)); + when(transactionService.save(any(Transaction.class))).thenReturn(existing); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_WEB")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + + handler.process(incoming); + + verify(paymentProcessorFactory).getPaymentProcessor("REQUEST_ecocash_WEB"); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/IntegrationHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/IntegrationHandlerTest.java new file mode 100644 index 0000000..cd4285b --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/IntegrationHandlerTest.java @@ -0,0 +1,176 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.RequestType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Provider; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.models.TransactionEvent; +import zw.qantra.tm.domain.repositories.ProviderRepository; +import zw.qantra.tm.domain.services.*; +import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; +import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; +import zw.qantra.tm.exceptions.ApiException; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class IntegrationHandlerTest { + + @Mock + private PaymentProcessorFactory paymentProcessorFactory; + + @Mock + private ProviderService providerService; + + @Mock + private RecipientService recipientService; + + @Mock + private TransactionEventService transactionEventService; + + @Mock + private TransactionService transactionService; + + @Mock + private ProviderRepository providerRepository; + + @Mock + private TransactionProcessorInterface transactionProcessor; + + @InjectMocks + private IntegrationHandler handler; + + private Transaction transaction; + private Provider provider; + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setId(UUID.randomUUID()); + transaction.setUserId("user-1"); + transaction.setBillClientId("client-123"); + transaction.setPollingStatus(Status.SUCCESS); + + provider = new Provider(); + provider.setIntegrationProcessorLabel("hotrecharge"); + + lenient().when(providerService.getProviderRepository()).thenReturn(providerRepository); + } + + @Test + @DisplayName("Should process integration successfully") + void shouldProcessIntegrationSuccessfully() throws Exception { + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("INTEGRATION_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getIntegrationStatus()); + assertEquals(Status.SUCCESS, result.getPaymentStatus()); + assertEquals("hotrecharge", result.getIntegrationProcessorLabel()); + } + + @Test + @DisplayName("Should return early when integration already successful") + void shouldReturnEarlyWhenAlreadySuccess() throws Exception { + transaction.setIntegrationStatus(Status.SUCCESS); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + verify(transactionProcessor, never()).process(any()); + } + + @Test + @DisplayName("Should throw exception when polling is not complete") + void shouldThrowWhenPollingNotComplete() { + transaction.setPollingStatus(Status.PENDING); + + assertThrows(ApiException.class, () -> handler.process(transaction)); + } + + @Test + @DisplayName("Should throw exception when provider not found") + void shouldThrowWhenProviderNotFound() { + when(providerRepository.findByClientId("client-123")).thenReturn(null); + + assertThrows(ApiException.class, () -> handler.process(transaction)); + } + + @Test + @DisplayName("Should set failed status when integration processor throws exception") + void shouldSetFailedStatusOnProcessorError() throws Exception { + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("INTEGRATION_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))) + .thenThrow(new RuntimeException("Integration failed")); + + assertThrows(ApiException.class, () -> handler.process(transaction)); + assertEquals(Status.FAILED, transaction.getStatus()); + assertEquals(Status.FAILED, transaction.getIntegrationStatus()); + assertEquals("01", transaction.getResponseCode()); + } + + @Test + @DisplayName("Should set type to INTEGRATION") + void shouldSetTypeToIntegration() throws Exception { + transaction.setType(RequestType.REQUEST); + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("INTEGRATION_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + handler.process(transaction); + + assertEquals(RequestType.INTEGRATION, transaction.getType()); + } + + @Test + @DisplayName("Should handle race condition with lock mechanism") + void shouldHandleRaceCondition() throws Exception { + when(providerRepository.findByClientId("client-123")).thenReturn(provider); + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("INTEGRATION_hotrecharge")) + .thenReturn(transactionProcessor); + when(transactionProcessor.process(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + // First call should succeed + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getIntegrationStatus()); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/PollStatusHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/PollStatusHandlerTest.java new file mode 100644 index 0000000..411d2b8 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/PollStatusHandlerTest.java @@ -0,0 +1,158 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.AuthType; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.models.TransactionEvent; +import zw.qantra.tm.domain.services.*; +import zw.qantra.tm.domain.services.factories.PaymentProcessorFactory; +import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; +import zw.qantra.tm.exceptions.ApiException; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PollStatusHandlerTest { + + @Mock + private PaymentProcessorFactory paymentProcessorFactory; + + @Mock + private TransactionEventService transactionEventService; + + @Mock + private TransactionService transactionService; + + @Mock + private TransactionProcessorInterface transactionProcessor; + + @InjectMocks + private PollStatusHandler handler; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setId(UUID.randomUUID()); + transaction.setUserId("user-1"); + transaction.setPaymentProcessorLabel("ecocash"); + transaction.setAuthType(AuthType.MOBILE); + } + + @Test + @DisplayName("Should poll and return success when gateway returns SUCCESS") + void shouldPollSuccessfully() throws Exception { + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.poll(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + assertEquals(Status.SUCCESS, result.getPollingStatus()); + assertEquals(Status.SUCCESS, result.getPaymentStatus()); + assertNull(result.getErrorMessage()); + } + + @Test + @DisplayName("Should return early when polling already successful") + void shouldReturnEarlyWhenAlreadySuccess() throws Exception { + transaction.setPollingStatus(Status.SUCCESS); + + Transaction result = (Transaction) handler.process(transaction); + + assertNotNull(result); + verify(transactionProcessor, never()).poll(any()); + verify(transactionService, never()).save(any()); + } + + @Test + @DisplayName("Should throw exception when poll returns non-SUCCESS status") + void shouldThrowWhenPollReturnsNonSuccess() throws Exception { + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.poll(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.PENDING); + tx.setErrorMessage("Still processing"); + return tx; + }); + + ApiException exception = assertThrows(ApiException.class, () -> handler.process(transaction)); + assertTrue(exception.getMessage().contains("Still processing")); + } + + @Test + @DisplayName("Should save transaction when poll status is not SUCCESS") + void shouldSaveTransactionWhenNotSuccess() throws Exception { + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.poll(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.PENDING); + tx.setErrorMessage("Verification pending"); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + assertThrows(ApiException.class, () -> handler.process(transaction)); + verify(transactionService).save(any(Transaction.class)); + } + + @Test + @DisplayName("Should set pollingStatus and paymentStatus from poll result") + void shouldSetStatusesFromPollResult() throws Exception { + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.poll(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + handler.process(transaction); + + assertEquals(Status.SUCCESS, transaction.getPollingStatus()); + assertEquals(Status.SUCCESS, transaction.getPaymentStatus()); + } + + @Test + @DisplayName("Should create POLL event on each poll attempt") + void shouldCreatePollEvent() throws Exception { + doNothing().when(transactionEventService).save(any(TransactionEvent.class)); + when(paymentProcessorFactory.getPaymentProcessor("REQUEST_ecocash_MOBILE")) + .thenReturn(transactionProcessor); + when(transactionProcessor.poll(any(Transaction.class))).thenAnswer(invocation -> { + Transaction tx = invocation.getArgument(0); + tx.setStatus(Status.SUCCESS); + return tx; + }); + when(transactionService.save(any(Transaction.class))).thenReturn(transaction); + + handler.process(transaction); + + verify(transactionEventService, times(2)).save(any(TransactionEvent.class)); + } +} \ No newline at end of file diff --git a/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/RecipientsUpdateHandlerTest.java b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/RecipientsUpdateHandlerTest.java new file mode 100644 index 0000000..66b5434 --- /dev/null +++ b/src/test/java/zw/qantra/tm/domain/services/processors/workflows/handlers/RecipientsUpdateHandlerTest.java @@ -0,0 +1,136 @@ +package zw.qantra.tm.domain.services.processors.workflows.handlers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zw.qantra.tm.domain.enums.Status; +import zw.qantra.tm.domain.models.Recipient; +import zw.qantra.tm.domain.models.Transaction; +import zw.qantra.tm.domain.services.RecipientService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecipientsUpdateHandlerTest { + + @Mock + private RecipientService recipientService; + + @InjectMocks + private RecipientsUpdateHandler handler; + + private Transaction transaction; + + @BeforeEach + void setUp() { + transaction = new Transaction(); + transaction.setStatus(Status.SUCCESS); + transaction.setCreditAccount("ACC-123"); + transaction.setUserId("user-1"); + transaction.setProviderLabel("econet"); + transaction.setCreditName("John Doe"); + transaction.setCreditEmail("john@example.com"); + transaction.setCreditPhone("+263771234567"); + transaction.setBillName("Econet Bundle"); + } + + @Test + @DisplayName("Should update existing recipient when found") + void shouldUpdateExistingRecipient() { + Recipient existingRecipient = new Recipient(); + existingRecipient.setAccount("ACC-123"); + existingRecipient.setUserId("user-1"); + existingRecipient.setName("Old Name"); + + when(recipientService.getRecipient("ACC-123", "user-1")) + .thenReturn(List.of(existingRecipient)); + when(recipientService.save(any(Recipient.class))).thenReturn(existingRecipient); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + assertEquals("John Doe", existingRecipient.getName()); + assertEquals("john@example.com", existingRecipient.getEmail()); + assertEquals("+263771234567", existingRecipient.getPhoneNumber()); + assertEquals("econet", existingRecipient.getLatestProviderLabel()); + assertEquals("JO", existingRecipient.getInitials()); + verify(recipientService).save(existingRecipient); + } + + @Test + @DisplayName("Should create new recipient when not found") + void shouldCreateNewRecipient() { + when(recipientService.getRecipient("ACC-123", "user-1")) + .thenReturn(List.of()); + when(recipientService.save(any(Recipient.class))).thenAnswer(i -> i.getArgument(0)); + + Transaction result = handler.process(transaction); + + assertNotNull(result); + verify(recipientService).save(any(Recipient.class)); + } + + @Test + @DisplayName("Should skip recipient update when transaction status is not SUCCESS") + void shouldSkipWhenNotSuccess() { + transaction.setStatus(Status.PENDING); + + handler.process(transaction); + + verify(recipientService, never()).getRecipient(any(), any()); + verify(recipientService, never()).save(any()); + } + + @Test + @DisplayName("Should use billName for initials when creditName is null") + void shouldUseBillNameForInitials() { + transaction.setCreditName(null); + + when(recipientService.getRecipient("ACC-123", "user-1")) + .thenReturn(List.of()); + when(recipientService.save(any(Recipient.class))).thenAnswer(i -> i.getArgument(0)); + + handler.process(transaction); + + verify(recipientService).save(any(Recipient.class)); + } + + @Test + @DisplayName("Should handle exception gracefully without throwing") + void shouldHandleExceptionGracefully() { + when(recipientService.getRecipient("ACC-123", "user-1")) + .thenThrow(new RuntimeException("DB error")); + + assertDoesNotThrow(() -> handler.process(transaction)); + } + + @Test + @DisplayName("Should not set null fields on recipient") + void shouldNotSetNullFields() { + transaction.setCreditName(null); + transaction.setCreditEmail(null); + transaction.setCreditPhone(null); + + Recipient existingRecipient = new Recipient(); + existingRecipient.setAccount("ACC-123"); + existingRecipient.setUserId("user-1"); + + when(recipientService.getRecipient("ACC-123", "user-1")) + .thenReturn(List.of(existingRecipient)); + when(recipientService.save(any(Recipient.class))).thenReturn(existingRecipient); + + handler.process(transaction); + + assertNull(existingRecipient.getName()); + assertNull(existingRecipient.getEmail()); + assertNull(existingRecipient.getPhoneNumber()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..1742323 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,24 @@ +# Test configuration +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +# Disable nFlow for unit tests +nflow.db.h2.url=jdbc:h2:mem:nflow-test;DB_CLOSE_DELAY=-1 +nflow.db.h2.user=sa +nflow.db.h2.password= +nflow.autostart=false + +# Disable security for tests +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,io.nflow.spring.boot.autoconfigure.NflowAutoConfiguration + +# Disable jobrunr for tests +jobrunr.enabled=false + +# Disable mail for tests +spring.mail.enabled=false \ No newline at end of file