diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/domain/SmsCampaignRepository.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/domain/SmsCampaignRepository.java index a36d43c12b3..6c06210b83b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/domain/SmsCampaignRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/domain/SmsCampaignRepository.java @@ -37,4 +37,8 @@ public interface SmsCampaignRepository extends JpaRepository, @Query("SELECT campaign FROM SmsCampaign campaign WHERE campaign.paramValue LIKE :reportPattern AND campaign.triggerType=:triggerType AND campaign.status=300") List findActiveSmsCampaigns(@Param("reportPattern") String reportPattern, @Param("triggerType") Integer triggerType); + + boolean existsByCampaignName(String campaignName); + + boolean existsByCampaignNameAndIdNot(String campaignName, Long id); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/exception/SmsCampaignNameAlreadyExistsException.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/exception/SmsCampaignNameAlreadyExistsException.java new file mode 100644 index 00000000000..6bc7e40dbcf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/exception/SmsCampaignNameAlreadyExistsException.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.campaigns.sms.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class SmsCampaignNameAlreadyExistsException extends AbstractPlatformDomainRuleException { + + public SmsCampaignNameAlreadyExistsException(final String name) { + super("error.msg.sms.campaign.duplicate.name", "An SMS campaign with name '" + name + "' already exists", name); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/service/SmsCampaignWritePlatformServiceJpaImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/service/SmsCampaignWritePlatformServiceJpaImpl.java index db4b9e82eac..ecbf6d56c96 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/service/SmsCampaignWritePlatformServiceJpaImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/campaigns/sms/service/SmsCampaignWritePlatformServiceJpaImpl.java @@ -47,6 +47,7 @@ import org.apache.fineract.infrastructure.campaigns.sms.domain.SmsCampaignRepository; import org.apache.fineract.infrastructure.campaigns.sms.exception.SmsCampaignMustBeClosedToBeDeletedException; import org.apache.fineract.infrastructure.campaigns.sms.exception.SmsCampaignMustBeClosedToEditException; +import org.apache.fineract.infrastructure.campaigns.sms.exception.SmsCampaignNameAlreadyExistsException; import org.apache.fineract.infrastructure.campaigns.sms.exception.SmsCampaignNotFound; import org.apache.fineract.infrastructure.campaigns.sms.serialization.SmsCampaignValidator; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -104,20 +105,32 @@ public class SmsCampaignWritePlatformServiceJpaImpl implements SmsCampaignWriteP @Transactional @Override public CommandProcessingResult create(JsonCommand command) { - final AppUser currentUser = this.context.authenticatedUser(); - this.smsCampaignValidator.validateCreate(command.json()); - final Long runReportId = command.longValueOfParameterNamed(SmsCampaignValidator.runReportId); - Report report = this.reportRepository.findById(runReportId).orElseThrow(() -> new ReportNotFoundException(runReportId)); - LocalDateTime tenantDateTime = DateUtils.getLocalDateTimeOfTenant(); - SmsCampaign smsCampaign = SmsCampaign.instance(currentUser, report, command); - LocalDateTime recurrenceStartDate = smsCampaign.getRecurrenceStartDate(); - if (recurrenceStartDate != null && DateUtils.isBefore(recurrenceStartDate, tenantDateTime)) { - throw new GeneralPlatformDomainRuleException("error.msg.campaign.recurrenceStartDate.in.the.past", - "Recurrence start date cannot be the past date.", recurrenceStartDate); - } - this.smsCampaignRepository.saveAndFlush(smsCampaign); + try { + final AppUser currentUser = this.context.authenticatedUser(); + this.smsCampaignValidator.validateCreate(command.json()); - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(smsCampaign.getId()).build(); + final String campaignName = command.stringValueOfParameterNamed(SmsCampaignValidator.campaignName); + if (this.smsCampaignRepository.existsByCampaignName(campaignName)) { + throw new SmsCampaignNameAlreadyExistsException(campaignName); + } + + final Long runReportId = command.longValueOfParameterNamed(SmsCampaignValidator.runReportId); + Report report = this.reportRepository.findById(runReportId).orElseThrow(() -> new ReportNotFoundException(runReportId)); + LocalDateTime tenantDateTime = DateUtils.getLocalDateTimeOfTenant(); + SmsCampaign smsCampaign = SmsCampaign.instance(currentUser, report, command); + LocalDateTime recurrenceStartDate = smsCampaign.getRecurrenceStartDate(); + if (recurrenceStartDate != null && DateUtils.isBefore(recurrenceStartDate, tenantDateTime)) { + throw new GeneralPlatformDomainRuleException("error.msg.campaign.recurrenceStartDate.in.the.past", + "Recurrence start date cannot be the past date.", recurrenceStartDate); + } + this.smsCampaignRepository.saveAndFlush(smsCampaign); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(smsCampaign.getId()).build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + final Throwable throwable = dve.getMostSpecificCause(); + handleDataIntegrityIssues(command, throwable); + return CommandProcessingResult.empty(); + } } @Transactional @@ -135,6 +148,13 @@ public CommandProcessingResult update(final Long resourceId, final JsonCommand c } final Map changes = smsCampaign.update(command); + if (changes.containsKey(SmsCampaignValidator.campaignName)) { + final String newName = (String) changes.get(SmsCampaignValidator.campaignName); + if (this.smsCampaignRepository.existsByCampaignNameAndIdNot(newName, resourceId)) { + throw new SmsCampaignNameAlreadyExistsException(newName); + } + } + if (changes.containsKey(SmsCampaignValidator.runReportId)) { final Long newValue = command.longValueOfParameterNamed(SmsCampaignValidator.runReportId); final Report reportId = this.reportRepository.findById(newValue).orElseThrow(() -> new ReportNotFoundException(newValue)); @@ -538,6 +558,10 @@ public CommandProcessingResult reactivateSmsCampaign(final Long campaignId, Json } private void handleDataIntegrityIssues(final JsonCommand command, final Throwable realCause) { + if (realCause.getMessage().contains("campaign_name_UNIQUE")) { + final String name = command.stringValueOfParameterNamed(SmsCampaignValidator.campaignName); + throw new SmsCampaignNameAlreadyExistsException(name); + } throw ErrorHandler.getMappable(realCause, "error.msg.sms.campaign.unknown.data.integrity.issue", "Unknown data integrity issue with resource: " + realCause.getMessage()); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SmsCampaignIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SmsCampaignIntegrationTest.java new file mode 100644 index 00000000000..04193ff99bd --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SmsCampaignIntegrationTest.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.HashMap; +import java.util.List; +import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.organisation.CampaignsHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.junit.jupiter.MockServerExtension; +import org.mockserver.junit.jupiter.MockServerSettings; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.model.MediaType; + +/** + * Integration tests for SMS Campaign duplicate name validation. + */ +@ExtendWith(MockServerExtension.class) +@MockServerSettings(ports = { 9191 }) +public class SmsCampaignIntegrationTest { + + private RequestSpecification requestSpec; + private ResponseSpecification responseSpec; + private ResponseSpecification errorResponseSpec; + private CampaignsHelper campaignsHelper; + private final ClientAndServer client; + + public SmsCampaignIntegrationTest(ClientAndServer client) { + this.client = client; + this.client.when(HttpRequest.request().withMethod("GET").withPath("/smsbridges")) + .respond(HttpResponse.response().withContentType(MediaType.APPLICATION_JSON).withBody( + "[{\"id\":1,\"tenantId\":1,\"phoneNo\":\"+1234567890\",\"providerName\":\"Dummy SMS Provider - Testing\",\"providerDescription\":\"Dummy, just for testing\"}]")); + } + + @BeforeEach + public void setup() { + Utils.initializeRESTAssured(); + this.requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + this.requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + this.requestSpec.header("Fineract-Platform-TenantId", "default"); + this.responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + this.errorResponseSpec = new ResponseSpecBuilder().expectStatusCode(403).build(); + this.campaignsHelper = new CampaignsHelper(this.requestSpec, this.responseSpec); + } + + @Test + public void testCreateCampaignWithDuplicateNameShouldFail() { + String reportName = "Prospective Clients"; + int triggerType = 1; + String campaignName = "Duplicate_Test_Campaign_" + System.currentTimeMillis(); + + // Create first campaign with specific name + Integer firstCampaignId = campaignsHelper.createCampaignWithName(reportName, triggerType, campaignName); + assertNotNull(firstCampaignId, "First campaign should be created successfully"); + campaignsHelper.verifyCampaignCreatedOnServer(requestSpec, responseSpec, firstCampaignId); + + // Attempt to create second campaign with the same name - should fail + List errors = campaignsHelper.createCampaignWithNameExpectingError(errorResponseSpec, reportName, triggerType, + campaignName); + + assertNotNull(errors, "Error response should not be null"); + assertEquals(1, errors.size(), "Should have exactly one error"); + assertEquals("error.msg.sms.campaign.duplicate.name", errors.get(0).get(CommonConstants.RESPONSE_ERROR_MESSAGE_CODE), + "Error code should indicate duplicate campaign name"); + } + + @Test + public void testCreateCampaignWithUniqueNameShouldSucceed() { + String reportName = "Prospective Clients"; + int triggerType = 1; + String campaignName1 = "Unique_Campaign_1_" + System.currentTimeMillis(); + String campaignName2 = "Unique_Campaign_2_" + System.currentTimeMillis(); + + // Create first campaign + Integer firstCampaignId = campaignsHelper.createCampaignWithName(reportName, triggerType, campaignName1); + assertNotNull(firstCampaignId, "First campaign should be created successfully"); + + // Create second campaign with different name - should succeed + Integer secondCampaignId = campaignsHelper.createCampaignWithName(reportName, triggerType, campaignName2); + assertNotNull(secondCampaignId, "Second campaign with different name should be created successfully"); + + // Verify both campaigns exist + campaignsHelper.verifyCampaignCreatedOnServer(requestSpec, responseSpec, firstCampaignId); + campaignsHelper.verifyCampaignCreatedOnServer(requestSpec, responseSpec, secondCampaignId); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/organisation/CampaignsHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/organisation/CampaignsHelper.java index 6e16ae923d6..e0c40c6fadc 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/organisation/CampaignsHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/organisation/CampaignsHelper.java @@ -65,6 +65,21 @@ public Integer createCampaign(String reportName, Integer triggerType) { "resourceId"); } + public Integer createCampaignWithName(String reportName, Integer triggerType, String campaignName) { + log.info("---------------------------------CREATING A CAMPAIGN WITH NAME---------------------------------------------"); + final String CREATE_SMS_CAMPAIGNS_URL = SMS_CAMPAIGNS_URL + "?" + Utils.TENANT_IDENTIFIER; + return Utils.performServerPost(requestSpec, responseSpec, CREATE_SMS_CAMPAIGNS_URL, + getCreateCampaignJSONWithName(reportName, triggerType, campaignName), "resourceId"); + } + + public List createCampaignWithNameExpectingError(ResponseSpecification errorResponseSpec, String reportName, + Integer triggerType, String campaignName) { + log.info("---------------------------------CREATING A CAMPAIGN WITH NAME (EXPECTING ERROR)---------------------"); + final String CREATE_SMS_CAMPAIGNS_URL = SMS_CAMPAIGNS_URL + "?" + Utils.TENANT_IDENTIFIER; + return Utils.performServerPost(requestSpec, errorResponseSpec, CREATE_SMS_CAMPAIGNS_URL, + getCreateCampaignJSONWithName(reportName, triggerType, campaignName), "errors"); + } + // TODO: Rewrite to use fineract-client instead! // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, // org.apache.fineract.client.models.PostLoansLoanIdRequest) @@ -132,6 +147,10 @@ public Object performActionsOnCampaignWithFailure(final Integer generatedCampaig // org.apache.fineract.client.models.PostLoansLoanIdRequest) @Deprecated(forRemoval = true) public String getCreateCampaignJSON(String reportName, Integer triggerType) { + return getCreateCampaignJSONWithName(reportName, triggerType, Utils.randomStringGenerator("Campaign_Name_", 5)); + } + + public String getCreateCampaignJSONWithName(String reportName, Integer triggerType, String campaignName) { final HashMap map = new HashMap<>(); final HashMap paramValueMap = new HashMap<>(); Long reportId = getSelectedReportId(reportName); @@ -143,7 +162,7 @@ public String getCreateCampaignJSON(String reportName, Integer triggerType) { map.put("frequency", 1); map.put("interval", "1"); } - map.put("campaignName", Utils.randomStringGenerator("Campaign_Name_", 5)); + map.put("campaignName", campaignName); map.put("campaignType", 1); map.put("message", "Hi, this is from integtration tests runner"); map.put("locale", "en");