Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ public interface SmsCampaignRepository extends JpaRepository<SmsCampaign, Long>,

@Query("SELECT campaign FROM SmsCampaign campaign WHERE campaign.paramValue LIKE :reportPattern AND campaign.triggerType=:triggerType AND campaign.status=300")
List<SmsCampaign> findActiveSmsCampaigns(@Param("reportPattern") String reportPattern, @Param("triggerType") Integer triggerType);

boolean existsByCampaignName(String campaignName);

boolean existsByCampaignNameAndIdNot(String campaignName, Long id);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Comment on lines +113 to +115
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the default isolation level we use is read_committed, meaning only committed things will be revealed by this check. It not guarantees anything.
2 transactions concurrently writing the same campaign name is possible.

If you wanna prevent it, ensure having a unique key on the DB level. This is good for a best-effort check but it's not bullet-proof,.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, there is already a unique key on the DB level. You might see further I catch DataIntegrityViolationException and throw the same exception.

see realCause.getMessage().contains("campaign_name_UNIQUE")


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
Expand All @@ -135,6 +148,13 @@ public CommandProcessingResult update(final Long resourceId, final JsonCommand c
}
final Map<String, Object> 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));
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HashMap> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap> 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)
Expand Down Expand Up @@ -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<String, Object> map = new HashMap<>();
final HashMap<String, Object> paramValueMap = new HashMap<>();
Long reportId = getSelectedReportId(reportName);
Expand All @@ -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");
Expand Down
Loading