diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java b/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java index de5fbb9..778031c 100644 --- a/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java +++ b/sendium-core/src/main/java/gr/cytech/sendium/core/http/KannelResource.java @@ -227,6 +227,11 @@ public Response receiveSms( } public void validateKannelAuth(String username, String password) { + if (Strings.isNullOrEmpty(username) || Strings.isNullOrEmpty(password)) { + throw new WebApplicationException(Response.status(Response.Status.UNAUTHORIZED) + .entity("Invalid credentials") + .build()); + } CredentialFileWatcher.Credential cred = credentialFileWatcher.getValidCredentials().get(username); if (cred == null || cred.type() != CredentialFileWatcher.CredentialType.HTTP) { logger.warn("No username found:{}", username); @@ -258,4 +263,4 @@ private String decodeTextParam(String value, String charsetName) { return URLDecoder.decode(value, StandardCharsets.UTF_8); } } -} \ No newline at end of file +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/auth/CredentialFileWatcherTest.java b/sendium-core/src/test/java/gr/cytech/sendium/auth/CredentialFileWatcherTest.java new file mode 100644 index 0000000..3219568 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/auth/CredentialFileWatcherTest.java @@ -0,0 +1,150 @@ +package gr.cytech.sendium.auth; + +import gr.cytech.sendium.conf.SendiumConfigurationHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CredentialFileWatcherTest { + + @TempDir Path tempDir; + + private CredentialFileWatcher watcher; + private ArrayList> notifications; + + @BeforeEach + void setUp() { + watcher = new CredentialFileWatcher(); + SendiumConfigurationHandler configHandler = new SendiumConfigurationHandler(); + configHandler.memoryConfiguration = new ConcurrentHashMap<>(); + configHandler.defaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.overriddenDefaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.currentStoreConfiguration = new ConcurrentHashMap<>(); + configHandler.listeners = new CopyOnWriteArraySet<>(); + watcher.configHandler = configHandler; + notifications = new ArrayList<>(); + } + + @Test + void reloadCredentialConfigurationParsesFileAndNotifiesListeners() throws Exception { + Path file = tempDir.resolve("credentials.yml"); + Files.writeString(file, """ + credentials: + - type: HTTP + systemId: http-user + password: secret + """); + watcher.addCredentialChangeListener(notifications::add); + + reload(file.toFile()); + + assertThat(watcher.getValidCredentials()).containsKey("http-user"); + assertThat(notifications).hasSize(1); + assertThat(notifications.getFirst()).containsKey("http-user"); + } + + @Test + void reloadCredentialConfigurationContinuesWhenListenerThrows() throws Exception { + Path file = tempDir.resolve("credentials.yml"); + Files.writeString(file, """ + credentials: + - type: HTTP + apiKey: api-key + """); + watcher.addCredentialChangeListener(credentials -> { + throw new RuntimeException("boom"); + }); + watcher.addCredentialChangeListener(notifications::add); + + reload(file.toFile()); + + assertThat(notifications).hasSize(1); + assertThat(notifications.getFirst()).containsKey("api-key"); + } + + @Test + void reloadCredentialConfigurationRetainsPreviousCredentialsWhenReloadFails() throws Exception { + Path file = tempDir.resolve("credentials.yml"); + Files.writeString(file, """ + credentials: + - type: SMPP + systemId: smpp-user + password: secret + """); + reload(file.toFile()); + + reload(tempDir.toFile()); + + assertThat(watcher.getValidCredentials()).containsKey("smpp-user"); + } + + @Test + void getValidCredentialsReturnsEmptyBeforeLoadAndUnmodifiableAfterLoad() throws Exception { + assertThat(watcher.getValidCredentials()).isEmpty(); + Path file = tempDir.resolve("credentials.yml"); + Files.writeString(file, """ + credentials: + - type: HTTP + apiKey: api-key + """); + reload(file.toFile()); + + assertThatThrownBy(() -> watcher.getValidCredentials().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void removeCredentialChangeListenerStopsNotifications() throws Exception { + Path file = tempDir.resolve("credentials.yml"); + Files.writeString(file, """ + credentials: + - type: HTTP + apiKey: api-key + """); + CredentialChangeListener listener = notifications::add; + watcher.addCredentialChangeListener(listener); + watcher.removeCredentialChangeListener(listener); + + reload(file.toFile()); + + assertThat(notifications).isEmpty(); + } + + @Test + void credentialValidationAndLookupKeyFollowCredentialType() { + var smpp = new CredentialFileWatcher.Credential( + CredentialFileWatcher.CredentialType.SMPP, "account", null, "system", "pass", null, null); + var httpApiKey = new CredentialFileWatcher.Credential( + CredentialFileWatcher.CredentialType.HTTP, "account", null, null, null, "api", null); + var httpUserPass = new CredentialFileWatcher.Credential( + CredentialFileWatcher.CredentialType.HTTP, "account", null, "user", "pass", null, null); + var invalid = new CredentialFileWatcher.Credential( + CredentialFileWatcher.CredentialType.HTTP, "account", null, null, null, null, null); + + assertThat(smpp.isValid()).isTrue(); + assertThat(smpp.getLookupKey()).isEqualTo("system"); + assertThat(httpApiKey.isValid()).isTrue(); + assertThat(httpApiKey.getLookupKey()).isEqualTo("api"); + assertThat(httpUserPass.isValid()).isTrue(); + assertThat(httpUserPass.getLookupKey()).isEqualTo("user"); + assertThat(invalid.isValid()).isFalse(); + } + + private void reload(File file) throws Exception { + Method method = CredentialFileWatcher.class.getDeclaredMethod("reloadCredentialConfiguration", File.class); + method.setAccessible(true); + method.invoke(watcher, file); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/conf/ConfFileWatcherTest.java b/sendium-core/src/test/java/gr/cytech/sendium/conf/ConfFileWatcherTest.java new file mode 100644 index 0000000..1d1e40b --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/conf/ConfFileWatcherTest.java @@ -0,0 +1,104 @@ +package gr.cytech.sendium.conf; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfFileWatcherTest { + + @TempDir Path tempDir; + + private SendiumConfigurationHandler configHandler; + private ConfFileWatcher watcher; + + @BeforeEach + void setUp() { + configHandler = new SendiumConfigurationHandler(); + configHandler.memoryConfiguration = new ConcurrentHashMap<>(); + configHandler.defaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.overriddenDefaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.currentStoreConfiguration = new ConcurrentHashMap<>(); + configHandler.listeners = new CopyOnWriteArraySet<>(); + + watcher = new ConfFileWatcher(); + watcher.configHandler = configHandler; + } + + @Test + void reloadConfigurationAddsChangesAndRemovesProperties() throws Exception { + Path file = tempDir.resolve("smsg.properties"); + var events = new ArrayList(); + configHandler.addPropertyChangeListener(events::add); + + Files.writeString(file, "alpha=one\npassword=secret\n"); + reload(file.toFile()); + + Files.writeString(file, "alpha=two\nbeta=three\n"); + reload(file.toFile()); + + assertThat(configHandler.get("alpha")).isEqualTo("two"); + assertThat(configHandler.get("beta")).isEqualTo("three"); + assertThat(configHandler.get("password")).isNull(); + assertThat(events).extracting(PropertyChangeEvent::getKey) + .containsExactlyInAnyOrder("alpha", "password", "alpha", "beta", "password"); + assertThat(events).anySatisfy(event -> { + assertThat(event.getKey()).isEqualTo("alpha"); + assertThat(event.getNewValue()).isEqualTo("two"); + assertThat(event.getOldValue()).isEqualTo("one"); + }); + assertThat(events).anySatisfy(event -> { + assertThat(event.getKey()).isEqualTo("password"); + assertThat(event.getNewValue()).isNull(); + assertThat(event.getOldValue()).isEqualTo("secret"); + }); + } + + @Test + void reloadConfigurationSkipsMissingFile() throws Exception { + Path file = tempDir.resolve("missing.properties"); + var events = new ArrayList(); + configHandler.addPropertyChangeListener(events::add); + + reload(file.toFile()); + + assertThat(events).isEmpty(); + assertThat(configHandler.memoryConfiguration).isEmpty(); + } + + @Test + void loadPropertiesFromFileReturnsNullForDirectory() throws Exception { + Method method = ConfFileWatcher.class.getDeclaredMethod("loadPropertiesFromFile", File.class); + method.setAccessible(true); + + Object result = method.invoke(watcher, tempDir.toFile()); + + assertThat(result).isNull(); + } + + @Test + void maskSecretMasksSensitiveKeysOnly() throws Exception { + Method method = ConfFileWatcher.class.getDeclaredMethod("maskSecret", String.class, String.class); + method.setAccessible(true); + + assertThat(method.invoke(watcher, "db.password", "secret")).isEqualTo("*****"); + assertThat(method.invoke(watcher, "api.token", "token")).isEqualTo("*****"); + assertThat(method.invoke(watcher, "plain.key", "value")).isEqualTo("value"); + assertThat(method.invoke(watcher, "plain.key", null)).isEqualTo("null"); + } + + private void reload(File file) throws Exception { + Method method = ConfFileWatcher.class.getDeclaredMethod("reloadConfiguration", File.class); + method.setAccessible(true); + method.invoke(watcher, file); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/conf/SendiumConfigurationHandlerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/conf/SendiumConfigurationHandlerTest.java new file mode 100644 index 0000000..6248272 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/conf/SendiumConfigurationHandlerTest.java @@ -0,0 +1,116 @@ +package gr.cytech.sendium.conf; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.assertj.core.api.Assertions.assertThat; + +class SendiumConfigurationHandlerTest { + + private SendiumConfigurationHandler handler; + + @BeforeEach + void setUp() { + handler = new SendiumConfigurationHandler(); + handler.memoryConfiguration = new ConcurrentHashMap<>(); + handler.defaultsConfiguration = new ConcurrentHashMap<>(); + handler.overriddenDefaultsConfiguration = new ConcurrentHashMap<>(); + handler.currentStoreConfiguration = new ConcurrentHashMap<>(); + handler.listeners = new CopyOnWriteArraySet<>(); + } + + @Test + void getUsesMemoryBeforeStoreAndDefaults() { + handler.defaultsConfiguration.put("conf.key", "default"); + handler.currentStoreConfiguration.put("conf.key", new Property("conf.key", "store", null)); + handler.set("conf.key", "memory"); + + assertThat(handler.get("conf.key")).isEqualTo("memory"); + } + + @Test + void getFallsBackFromInstanceKeyToDefaultKey() { + handler.defaultsConfiguration.put("outSms.default.tps", "25"); + + assertThat(handler.get("outSms.instance.worker1.tps")).isEqualTo("25"); + } + + @Test + void setDefaultOverridesLoadedDefaultAndRemoveClearsOverride() { + handler.defaultsConfiguration.put("conf.key", "default"); + + handler.setDefault("conf.key", "override"); + assertThat(handler.get("conf.key")).isEqualTo("override"); + + handler.remove("conf.key"); + assertThat(handler.get("conf.key")).isEqualTo("default"); + } + + @Test + void typedGettersReturnFallbackWhenParsingFails() { + handler.set("bad.int", "not-int"); + handler.set("bad.long", "not-long"); + + assertThat(handler.getIntPrpt("bad.int", 7)).isEqualTo(7); + assertThat(handler.getLongPrpt("bad.long", 9L)).isEqualTo(9L); + assertThat(handler.getBlnPrpt("missing.bool", true)).isTrue(); + } + + @Test + void setPropertyAndNotifySendsOldAndNewValues() { + var events = new ArrayList(); + handler.set("conf.key", "old"); + handler.addPropertyChangeListener(events::add); + + String previous = handler.setPropertyAndNotify("conf.key", "new"); + + assertThat(previous).isEqualTo("old"); + assertThat(events).hasSize(1); + assertThat(events.getFirst().getKey()).isEqualTo("conf.key"); + assertThat(events.getFirst().getNewValue()).isEqualTo("new"); + assertThat(events.getFirst().getOldValue()).isEqualTo("old"); + } + + @Test + void firePropertyChangeContinuesWhenOneListenerThrows() { + var events = new ArrayList(); + handler.addPropertyChangeListener(evt -> { + throw new RuntimeException("boom"); + }); + handler.addPropertyChangeListener(events::add); + + handler.firePropertyChangeEvent("conf.key", "new", "old"); + + assertThat(events).hasSize(1); + assertThat(events.getFirst().getOldValue()).isEqualTo("old"); + } + + @Test + void loadDefaultParamsPrefixesKeysOnlyOnce() { + String[][] params = {{"host", "localhost"}, {"outSms.instance.worker.port", "2775"}}; + + handler.loadDefaultParams("outSms.instance.worker", params); + + assertThat(handler.defaultsConfiguration) + .containsEntry("outSms.instance.worker.host", "localhost") + .containsEntry("outSms.instance.worker.port", "2775"); + } + + @Test + void storePropertiesWritesValuesAndRemovesNullValues() { + handler.set("conf.old", "value"); + + boolean result = handler.storeProperties(Map.of( + "conf.new", new Property("conf.new", "new-value", null), + "conf.old", new Property("conf.old", null, null)), false); + + assertThat(result).isFalse(); + assertThat(handler.get("conf.new")).isEqualTo("new-value"); + assertThat(handler.get("conf.old")).isNull(); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java index 2ab81ed..634d735 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java @@ -2,6 +2,8 @@ import gr.cytech.sendium.core.message.StandardMessage; import gr.cytech.sendium.core.queue.Queue; +import gr.cytech.sendium.core.worker.InMemoryDlrService; +import gr.cytech.sendium.core.worker.MessageState; import gr.cytech.sendium.routing.OutgoingWorkerManager; import gr.cytech.sendium.routing.StandardOutgoingWorkerHandler; import io.quarkus.test.junit.QuarkusTest; @@ -22,6 +24,7 @@ @QuarkusTest class KannelResourceIT { static StandardOutgoingWorkerHandler outgoingWorkerHandler; + static InMemoryDlrService dlrService; CaptorWorker captorWorker; private final String usernamekannel = "test2"; @@ -31,12 +34,14 @@ class KannelResourceIT { @BeforeAll static void beforeAll() { outgoingWorkerHandler = (StandardOutgoingWorkerHandler) CDI.current().select(OutgoingWorkerManager.class).get(); + dlrService = CDI.current().select(InMemoryDlrService.class).get(); } @BeforeEach void setUp() { captorWorker = (CaptorWorker) outgoingWorkerHandler.getWorkers().get(captorInstanceName); captorWorker.getRouterQueue().drainTo(new Queue<>()); + captorWorker.captures.clear(); } @Test @@ -55,9 +60,10 @@ void testMissingToParameter() { } @Test - @DisplayName("Should return 400 BAD REQUEST when 'to' parameter is missing") + @DisplayName("Should return 400 BAD REQUEST when 'from' parameter is missing") void testMissingFromParameter() { given() + .queryParam("to", "123456789") .queryParam("username", usernamekannel) .queryParam("password", passwordKannel) .queryParam("text", "Hello World") @@ -65,7 +71,7 @@ void testMissingFromParameter() { .get("/sendsms") .then() .statusCode(400) - .body(org.hamcrest.Matchers.equalTo("Missing 'to' parameter")); + .body(org.hamcrest.Matchers.equalTo("Missing 'from' parameter")); } @Test @@ -183,4 +189,98 @@ void testInvalidCredentials() { .statusCode(401) .body(org.hamcrest.Matchers.equalTo("Invalid credentials")); } -} \ No newline at end of file + + @Test + @DisplayName("Should return 401 Unauthorized when password is missing") + void testMissingPassword() { + given() + .queryParam("to", "123456789") + .queryParam("from", "Sender") + .queryParam("text", "Hello World") + .queryParam("username", usernamekannel) + .when() + .get("/sendsms") + .then() + .statusCode(401) + .body(org.hamcrest.Matchers.equalTo("Invalid credentials")); + } + + @Test + @DisplayName("Should prefer user/pass aliases over username/password") + void testUserPassAliasesOverrideUsernamePassword() throws InterruptedException { + given() + .queryParam("to", "123456789") + .queryParam("from", "Sender") + .queryParam("text", "Hello Alias") + .queryParam("username", "bad-user") + .queryParam("password", "bad-pass") + .queryParam("user", usernamekannel) + .queryParam("pass", passwordKannel) + .when() + .get("/sendsms") + .then() + .statusCode(202); + + var capturedMsg = captorWorker.captures.poll(10, TimeUnit.SECONDS); + assertThat(capturedMsg).isNotNull(); + assertThat(capturedMsg.owner_id).isEqualTo(usernamekannel); + assertThat(capturedMsg.body).isEqualTo("Hello Alias"); + } + + @Test + @DisplayName("Should map optional Kannel fields and create initial DLR state") + void testOptionalFieldsAndDlrStateCreation() throws InterruptedException { + String serial = given() + .queryParam("to", "987654321") + .queryParam("from", "Sender") + .queryParam("text", "Binary-ish") + .queryParam("username", usernamekannel) + .queryParam("password", passwordKannel) + .queryParam("account", "customer-account") + .queryParam("smsc", "smsc-route") + .queryParam("mclass", 1) + .queryParam("coding", 1) + .queryParam("validity", 60) + .queryParam("deferred", 5) + .queryParam("udh", "050003010201") + .queryParam("pid", 12) + .queryParam("alt-dcs", 8) + .queryParam("rpi", 1) + .queryParam("binfo", "billing-info") + .queryParam("priority", 3) + .queryParam("dlr-url", "http://callback.test/dlr?id=%I&status=%d") + .when() + .get("/sendsms") + .then() + .statusCode(202) + .extract() + .asString(); + + var capturedMsg = captorWorker.captures.poll(10, TimeUnit.SECONDS); + assertThat(capturedMsg).isNotNull(); + assertThat(capturedMsg.serial).isEqualTo(serial); + assertThat(capturedMsg.owner_id).isEqualTo("customer-account"); + assertThat(capturedMsg.message_center).isEqualTo("smsc-route"); + assertThat(capturedMsg.mclass).isEqualTo(1); + assertThat(capturedMsg.dcs).isEqualTo(StandardMessage.DCS_8BIT); + assertThat(capturedMsg.ttl).isEqualTo(60); + assertThat(capturedMsg.ddt).isEqualTo(5); + assertThat(capturedMsg.binheader).isEqualTo("050003010201"); + assertThat(capturedMsg.field1).isEqualTo(12); + assertThat(capturedMsg.field2).isEqualTo(8); + assertThat(capturedMsg.field3).isEqualTo(1); + assertThat(capturedMsg.field4).isEqualTo("billing-info"); + assertThat(capturedMsg.priority).isEqualTo(3); + assertThat(capturedMsg.acked).isTrue(); + + var state = dlrService.getState(serial); + assertThat(state).isPresent(); + assertThat(state.get().getGatewayMsgId()).isEqualTo(serial); + assertThat(state.get().getAccountId()).isEqualTo(usernamekannel); + assertThat(state.get().getSystemId()).isEqualTo(usernamekannel); + assertThat(state.get().getSourceAddr()).isEqualTo("Sender"); + assertThat(state.get().getDestAddr()).isEqualTo("987654321"); + assertThat(state.get().getForwardDlrUrl()).isEqualTo("http://callback.test/dlr?id=%I&status=%d"); + assertThat(state.get().getStatus()).isEqualTo(MessageState.MessageStatus.ACCEPTED); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/queue/QueueTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/queue/QueueTest.java new file mode 100644 index 0000000..ba5da31 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/queue/QueueTest.java @@ -0,0 +1,118 @@ +package gr.cytech.sendium.core.queue; + +import gr.cytech.sendium.core.message.StandardMessage; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.concurrent.SynchronousQueue; + +import static org.assertj.core.api.Assertions.assertThat; + +class QueueTest { + + @Test + void defaultQueueDequeuesInFifoOrder() throws Exception { + Queue queue = new Queue<>(); + StandardMessage first = message("first", 1); + StandardMessage second = message("second", 3); + + queue.enqueue(first); + queue.enqueue(second); + + assertThat(queue.dequeue(100)).isSameAs(first); + assertThat(queue.dequeue(100)).isSameAs(second); + assertThat(queue.isEmpty()).isTrue(); + } + + @Test + void priorityQueueDequeuesHighestPriorityFirst() throws Exception { + Queue queue = new Queue<>(true); + StandardMessage low = message("low", StandardMessage.LOW_PRIORITY); + StandardMessage high = message("high", StandardMessage.HIGH_PRIORITY); + StandardMessage normal = message("normal", StandardMessage.NORMAL_PRIORITY); + + queue.enqueue(low); + queue.enqueue(high); + queue.enqueue(normal); + + assertThat(queue.dequeue(100)).isSameAs(high); + assertThat(queue.dequeue(100)).isSameAs(normal); + assertThat(queue.dequeue(100)).isSameAs(low); + } + + @Test + void setHonourPrioritiesSwitchesImplementationAndPreservesMessages() throws Exception { + Queue queue = new Queue<>(); + StandardMessage low = message("low", StandardMessage.LOW_PRIORITY); + StandardMessage high = message("high", StandardMessage.HIGH_PRIORITY); + queue.enqueue(low); + queue.enqueue(high); + + boolean changed = queue.setHonourPriorities(true); + + assertThat(changed).isTrue(); + assertThat(queue.dequeue(100)).isSameAs(high); + assertThat(queue.dequeue(100)).isSameAs(low); + assertThat(queue.setHonourPriorities(true)).isFalse(); + } + + @Test + void enqueueNullDoesNotChangeQueue() throws Exception { + Queue queue = new Queue<>(); + + queue.enqueue(null); + + assertThat(queue.size()).isZero(); + assertThat(queue.dequeue(10)).isNull(); + } + + @Test + void drainToQueueWithMaxMovesOnlyRequestedMessages() throws Exception { + Queue source = new Queue<>(); + Queue target = new Queue<>(); + StandardMessage first = message("first", 1); + StandardMessage second = message("second", 1); + StandardMessage third = message("third", 1); + source.enqueue(first); + source.enqueue(second); + source.enqueue(third); + + int drained = source.drainTo(target, 2); + + assertThat(drained).isEqualTo(2); + assertThat(source.size()).isEqualTo(1); + assertThat(target.size()).isEqualTo(2); + assertThat(target.dequeue(100)).isSameAs(first); + assertThat(target.dequeue(100)).isSameAs(second); + } + + @Test + void drainToCollectionReturnsTransferredCount() throws Exception { + Queue queue = new Queue<>(); + queue.enqueue(message("first", 1)); + queue.enqueue(message("second", 1)); + ArrayList messages = new ArrayList<>(); + + int drained = queue.drainTo(messages); + + assertThat(drained).isEqualTo(2); + assertThat(messages).hasSize(2); + assertThat(queue.isEmpty()).isTrue(); + } + + @Test + void setHonourPrioritiesReturnsFalseForCustomQueueImplementation() { + Queue queue = new Queue<>(new SynchronousQueue<>()); + + boolean changed = queue.setHonourPriorities(true); + + assertThat(changed).isFalse(); + } + + private StandardMessage message(String serial, int priority) { + StandardMessage message = new StandardMessage(); + message.serial = serial; + message.priority = priority; + return message; + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientSessionHandlerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientSessionHandlerTest.java new file mode 100644 index 0000000..f2f5fa1 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientSessionHandlerTest.java @@ -0,0 +1,145 @@ +package gr.cytech.sendium.core.smpp.client; + +import com.cloudhopper.smpp.PduAsyncResponse; +import com.cloudhopper.smpp.SmppConstants; +import com.cloudhopper.smpp.pdu.DeliverSm; +import com.cloudhopper.smpp.pdu.EnquireLink; +import com.cloudhopper.smpp.pdu.PduResponse; +import com.cloudhopper.smpp.pdu.QuerySm; +import com.cloudhopper.smpp.pdu.SubmitSm; +import com.cloudhopper.smpp.pdu.SubmitSmResp; +import gr.cytech.sendium.core.message.StandardMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmppClientSessionHandlerTest { + + @Mock private SmppClientWorker worker; + + private SmppClientSessionHandler handler; + + private SmppClientWorker.ConnectionInfo connectionInfo; + + private DeliverSm moResponseSource; + private DeliverSm dlrResponseSource; + + @BeforeEach + void setUp() { + when(worker.getFullName()).thenReturn("smppclient.test"); + connectionInfo = new SmppClientWorker.ConnectionInfo(null, "localhost", 2775, + SmppClientWorker.ConnectionType.NORMAL); + handler = new SmppClientSessionHandler(worker, connectionInfo); + moResponseSource = new DeliverSm(); + dlrResponseSource = new DeliverSm(); + } + + @Test + void firePduRequestReceived_whenDeliverSmIsDeliveryReceipt_delegatesToDlrParser() { + DeliverSm deliverSm = new DeliverSm(); + deliverSm.setEsmClass(SmppConstants.ESM_CLASS_MT_SMSC_DELIVERY_RECEIPT); + PduResponse expected = dlrResponseSource.createResponse(); + when(worker.parseDlrAndCreateResponse(deliverSm)).thenReturn(expected); + + PduResponse response = handler.firePduRequestReceived(deliverSm); + + assertThat(response).isSameAs(expected); + assertThat(response.getReferenceObject()).isSameAs(handler); + verify(worker, never()).parseMoAndCreateResponse(any()); + } + + @Test + void firePduRequestReceived_whenDeliverSmIsMo_delegatesToMoParser() { + DeliverSm deliverSm = new DeliverSm(); + PduResponse expected = moResponseSource.createResponse(); + when(worker.parseMoAndCreateResponse(deliverSm)).thenReturn(expected); + + PduResponse response = handler.firePduRequestReceived(deliverSm); + + assertThat(response).isSameAs(expected); + assertThat(response.getReferenceObject()).isSameAs(handler); + verify(worker, never()).parseDlrAndCreateResponse(any()); + } + + @Test + void firePduRequestReceived_whenSubmitSmIsReceived_treatsItAsMo() { + SubmitSm submitSm = new SubmitSm(); + PduResponse expected = submitSm.createResponse(); + when(worker.parseMoAndCreateResponse(submitSm)).thenReturn(expected); + + PduResponse response = handler.firePduRequestReceived(submitSm); + + assertThat(response).isSameAs(expected); + verify(worker).parseMoAndCreateResponse(submitSm); + } + + @Test + void firePduRequestReceived_whenUnsupportedCommand_returnsInvalidCommandNack() { + QuerySm querySm = new QuerySm(); + + PduResponse response = handler.firePduRequestReceived(querySm); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_INVCMDID); + } + + @Test + void fireExpectedPduResponseReceived_whenSubmitResponseHasAttachedMessage_delegatesToWorker() { + StandardMessage msg = new StandardMessage(); + SubmitSm submitSm = new SubmitSm(); + submitSm.setReferenceObject(msg); + SubmitSmResp submitSmResp = new SubmitSmResp(); + submitSmResp.setCommandStatus(SmppConstants.STATUS_OK); + submitSmResp.setMessageId("smsc-1"); + PduAsyncResponse asyncResponse = mock(PduAsyncResponse.class); + when(asyncResponse.getRequest()).thenReturn(submitSm); + when(asyncResponse.getResponse()).thenReturn(submitSmResp); + + handler.fireExpectedPduResponseReceived(asyncResponse); + + verify(worker).handleResponse(handler, SmppConstants.STATUS_OK, "smsc-1", msg); + } + + @Test + void fireExpectedPduResponseReceived_whenSubmitResponseHasNoMessage_ignoresResponse() { + SubmitSm submitSm = new SubmitSm(); + SubmitSmResp submitSmResp = new SubmitSmResp(); + PduAsyncResponse asyncResponse = mock(PduAsyncResponse.class); + when(asyncResponse.getRequest()).thenReturn(submitSm); + when(asyncResponse.getResponse()).thenReturn(submitSmResp); + + handler.fireExpectedPduResponseReceived(asyncResponse); + + verify(worker, never()).handleResponse(eq(handler), any(Integer.class), any(), any()); + } + + @Test + void firePduRequestExpired_whenSubmitHasAttachedMessage_marksDeliveryFailure() { + StandardMessage msg = new StandardMessage(); + SubmitSm submitSm = new SubmitSm(); + submitSm.setReferenceObject(msg); + + handler.firePduRequestExpired(submitSm); + + verify(worker).handleResponse(handler, SmppConstants.STATUS_DELIVERYFAILURE, null, msg); + } + + @Test + void firePduRequestExpired_whenEnquireLinkExpiresAfterThreshold_removesConnection() { + when(worker.getMaxConsecutiveFailedEnquireLinksBeforeReconnecting()).thenReturn(1); + + handler.firePduRequestExpired(new EnquireLink()); + + verify(worker).removeConnection(handler, true, true); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java new file mode 100644 index 0000000..ef774e0 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java @@ -0,0 +1,400 @@ +package gr.cytech.sendium.core.smpp.client; + +import com.cloudhopper.commons.charset.CharsetUtil; +import com.cloudhopper.smpp.SmppConstants; +import com.cloudhopper.smpp.pdu.DeliverSm; +import com.cloudhopper.smpp.pdu.PduResponse; +import com.cloudhopper.smpp.pdu.SubmitSm; +import com.cloudhopper.smpp.tlv.Tlv; +import com.cloudhopper.smpp.type.Address; +import gr.cytech.sendium.conf.PropertyChangeListener; +import gr.cytech.sendium.conf.SendiumConfigurationProvider; +import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.queue.Queue; +import gr.cytech.sendium.core.worker.ForwardMoService; +import gr.cytech.sendium.core.worker.Tracker; +import gr.cytech.sendium.external.WorkerResourceProvider; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +class SmppClientWorkerTest { + + @Test + void parseDlrAndCreateResponse_whenReceiptIsValid_enqueuesDlrWithRegisteredTlvs() throws Exception { + TestConfigurationProvider config = new TestConfigurationProvider(Map.of( + "registered.tlvs.dlr", "carrier_1400")); + CapturingTracker tracker = new CapturingTracker(); + TestSmppClientWorker worker = new TestSmppClientWorker(config, new Queue<>(), tracker); + + DeliverSm deliverSm = new DeliverSm(); + deliverSm.setSourceAddress(new Address((byte) 1, (byte) 1, "smsc")); + deliverSm.setDestAddress(new Address((byte) 1, (byte) 1, "recipient")); + deliverSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + deliverSm.setShortMessage(CharsetUtil.encode( + "id:abc123 sub:001 dlvrd:001 submit date:2401010000 done date:2401010001 stat:DELIVRD err:000 text:ok", + CharsetUtil.NAME_GSM)); + deliverSm.addOptionalParameter(new Tlv((short) 1400, "network-a".getBytes())); + + PduResponse response = worker.parseDlrAndCreateResponse(deliverSm); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK); + assertThat(tracker.dlrSmscId).isEqualTo("abc123"); + assertThat(tracker.dlrFrom).isEqualTo("smsc"); + assertThat(tracker.dlrTo).isEqualTo("recipient"); + assertThat(tracker.dlrState).isEqualTo(StandardMessage.DLR_STAT_DELIVRD); + assertThat(tracker.dlrTlvs).containsEntry("carrier_1400", "network-a"); + } + + @Test + void parseDlrAndCreateResponse_whenReceiptHasNoMessageId_returnsSystemError() throws Exception { + TestSmppClientWorker worker = new TestSmppClientWorker(new TestConfigurationProvider(), new Queue<>(), new CapturingTracker()); + DeliverSm deliverSm = new DeliverSm(); + deliverSm.setSourceAddress(new Address((byte) 1, (byte) 1, "smsc")); + deliverSm.setDestAddress(new Address((byte) 1, (byte) 1, "recipient")); + deliverSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + deliverSm.setShortMessage(CharsetUtil.encode( + "id: sub:001 dlvrd:000 submit date:2401010000 done date:2401010001 stat:UNDELIV err:001 text:failed", + CharsetUtil.NAME_GSM)); + + PduResponse response = worker.parseDlrAndCreateResponse(deliverSm); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR); + } + + @Test + void parseMoAndCreateResponse_whenForwardUrlConfigured_forwardsDecodedMo() throws Exception { + TestConfigurationProvider config = new TestConfigurationProvider(Map.of( + "forward.mo.url", "http://example.test/mo", + "forward.mo.format", "JSON")); + CapturingForwardMoService forwardMoService = new CapturingForwardMoService(); + TestSmppClientWorker worker = new TestSmppClientWorker(config, new Queue<>(), new CapturingTracker(), + new TestWorkerResourceProvider(forwardMoService)); + DeliverSm deliverSm = new DeliverSm(); + deliverSm.setSourceAddress(new Address((byte) 1, (byte) 1, "sender")); + deliverSm.setDestAddress(new Address((byte) 1, (byte) 1, "shortcode")); + deliverSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + deliverSm.setShortMessage(CharsetUtil.encode("hello\0", CharsetUtil.NAME_GSM)); + + PduResponse response = worker.parseMoAndCreateResponse(deliverSm); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK); + assertThat(forwardMoService.forwardUrl).isEqualTo("http://example.test/mo"); + assertThat(forwardMoService.format).isEqualTo(ForwardMoService.ForwardFormat.JSON); + assertThat(forwardMoService.context.from()).isEqualTo("sender"); + assertThat(forwardMoService.context.to()).isEqualTo("shortcode"); + assertThat(forwardMoService.context.text()).isEqualTo("hello"); + assertThat(forwardMoService.context.ingateway()).isEmpty(); + assertThat(forwardMoService.context.messageCenter()).isEmpty(); + assertThat(forwardMoService.context.dataCoding()).isEqualTo(SmppConstants.DATA_CODING_DEFAULT); + } + + @Test + void parseMoAndCreateResponse_whenPayloadTlvAndUdhi_forwardsBodyWithoutHeader() throws Exception { + TestConfigurationProvider config = new TestConfigurationProvider(Map.of( + "forward.mo.url", "http://example.test/mo")); + CapturingForwardMoService forwardMoService = new CapturingForwardMoService(); + TestSmppClientWorker worker = new TestSmppClientWorker(config, new Queue<>(), new CapturingTracker(), + new TestWorkerResourceProvider(forwardMoService)); + SubmitSm submitSm = new SubmitSm(); + submitSm.setSourceAddress(new Address((byte) 1, (byte) 1, "sender")); + submitSm.setDestAddress(new Address((byte) 1, (byte) 1, "shortcode")); + submitSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + submitSm.setEsmClass(SmppConstants.ESM_CLASS_UDHI_MASK); + submitSm.addOptionalParameter(new Tlv( + SmppConstants.TAG_MESSAGE_PAYLOAD, + new byte[]{0x05, 0x00, 0x03, 0x01, 0x02, 0x01, 'H', 'i'})); + + PduResponse response = worker.parseMoAndCreateResponse(submitSm); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK); + assertThat(forwardMoService.context.text()).isEqualTo("Hi"); + assertThat(forwardMoService.context.from()).isEqualTo("sender"); + assertThat(forwardMoService.context.to()).isEqualTo("shortcode"); + } + + @Test + void handleResponse_whenStatusOk_marksSuccess() { + TestSmppClientWorker worker = new TestSmppClientWorker(new TestConfigurationProvider(), new Queue<>(), new CapturingTracker()); + StandardMessage msg = messageWithNetwork(); + + worker.handleResponse(handler(worker), SmppConstants.STATUS_OK, "smsc-1", msg); + + assertThat(worker.success).containsExactly(msg); + assertThat(worker.temporaryFailures).isEmpty(); + assertThat(worker.failures).isEmpty(); + } + + @Test + void handleResponse_whenRetryWorkerStatus_marksTemporaryFailure() { + TestSmppClientWorker worker = new TestSmppClientWorker(new TestConfigurationProvider(), new Queue<>(), new CapturingTracker()); + StandardMessage msg = messageWithNetwork(); + + worker.handleResponse(handler(worker), SmppConstants.STATUS_THROTTLED, null, msg); + + assertThat(worker.temporaryFailures).containsExactly(msg); + } + + @Test + void handleResponse_whenRetryRouterStatus_removesHlrAndFailsToRouter() { + TestConfigurationProvider config = new TestConfigurationProvider(Map.of( + "status.retry.router", Integer.toString(SmppConstants.STATUS_INVDSTADR))); + TestSmppClientWorker worker = new TestSmppClientWorker(config, new Queue<>(), new CapturingTracker()); + StandardMessage msg = messageWithNetwork(); + + worker.handleResponse(handler(worker), SmppConstants.STATUS_INVDSTADR, null, msg); + + assertThat(worker.failures).containsExactly(msg); + assertThat(msg.cnetwork).isZero(); + assertThat(msg.outgateway).isEmpty(); + } + + @Test + void handleResponse_whenFailStatus_recordsFailureDlr() { + TestConfigurationProvider config = new TestConfigurationProvider(Map.of( + "status.fail", Integer.toString(SmppConstants.STATUS_INVMSGLEN), + "resp.errcodes", SmppConstants.STATUS_INVMSGLEN + "_7")); + CapturingTracker tracker = new CapturingTracker(); + TestSmppClientWorker worker = new TestSmppClientWorker(config, new Queue<>(), tracker); + StandardMessage msg = messageWithNetwork(); + + worker.handleResponse(handler(worker), SmppConstants.STATUS_INVMSGLEN, "smsc-2", msg); + + assertThat(tracker.dlrMqId).isEqualTo(17); + assertThat(tracker.dlrSmscId).isEqualTo("smsc-2"); + assertThat(tracker.dlrState).isEqualTo(StandardMessage.DLR_STAT_FAILED); + assertThat(tracker.dlrErrorCode).isEqualTo("7"); + } + + private static SmppClientSessionHandler handler(TestSmppClientWorker worker) { + return new SmppClientSessionHandler(worker, new SmppClientWorker.ConnectionInfo( + null, "localhost", 2775, SmppClientWorker.ConnectionType.NORMAL)); + } + + private static StandardMessage messageWithNetwork() { + StandardMessage msg = new StandardMessage(); + msg.msgId = 17; + msg.from = "from"; + msg.to = "to"; + msg.cnetwork = 20201; + msg.outgateway = "hlr-route"; + return msg; + } + + private static class TestSmppClientWorker extends SmppClientWorker { + private final java.util.List success = new java.util.ArrayList<>(); + private final java.util.List temporaryFailures = new java.util.ArrayList<>(); + private final java.util.List failures = new java.util.ArrayList<>(); + + TestSmppClientWorker(SendiumConfigurationProvider configurationProvider, + Queue routerQueue, + Tracker tracker) { + super(configurationProvider, routerQueue, new ScheduledThreadPoolExecutor(1)); + this.messageTracker = tracker; + } + + TestSmppClientWorker(SendiumConfigurationProvider configurationProvider, + Queue routerQueue, + Tracker tracker, + WorkerResourceProvider workerResourceProvider) { + this(configurationProvider, routerQueue, tracker); + this.workerResources = workerResourceProvider; + } + + @Override + protected void successMessage(String respMessageId, StandardMessage msg) { + success.add(msg); + } + + @Override + public void onMessageTemporaryFailed(StandardMessage m) { + temporaryFailures.add(m); + } + + @Override + public void onMessageFailed(StandardMessage m) { + failures.add(m); + } + } + + private static class TestWorkerResourceProvider extends WorkerResourceProvider { + private final ForwardMoService forwardMoService; + + private TestWorkerResourceProvider(ForwardMoService forwardMoService) { + this.forwardMoService = forwardMoService; + } + + @Override + public ForwardMoService getForwardMoService() { + return forwardMoService; + } + } + + private static class CapturingForwardMoService extends ForwardMoService { + private String forwardUrl; + private MoContext context; + private ForwardFormat format; + + @Override + public void forwardMo(String forwardUrl, MoContext ctx, ForwardFormat format) { + this.forwardUrl = forwardUrl; + this.context = ctx; + this.format = format; + } + } + + private static class CapturingTracker implements Tracker { + private int dlrMqId; + private String dlrSmscId; + private String dlrFrom; + private String dlrTo; + private int dlrState; + private String dlrErrorCode; + private HashMap dlrTlvs; + + @Override + public void init() { + } + + @Override + public boolean stop() { + return true; + } + + @Override + public void configure(String key, String newValue, String oldValue) { + } + + @Override + public int updateSendStatusAndExtID(String smsid, StandardMessage pMsg, String smscid) { + return 1; + } + + @Override + public String getHashedMessageID(String messageId) { + return "hashed-" + messageId; + } + + @Override + public String getVendorPriceGateway() { + return ""; + } + + @Override + public void createAndEnqueueDLR(int mqid, String smscid, String smsid, String from, String to, String body, + int state, String errorCode, HashMap tlvs) { + this.dlrMqId = mqid; + this.dlrSmscId = smscid; + this.dlrFrom = from; + this.dlrTo = to; + this.dlrState = state; + this.dlrErrorCode = errorCode; + this.dlrTlvs = tlvs; + } + + @Override + public int getConfiguredMccMnc() { + return 0; + } + } + + private static class TestConfigurationProvider implements SendiumConfigurationProvider { + private final Map props = new HashMap<>(); + + TestConfigurationProvider() { + this(Map.of()); + } + + TestConfigurationProvider(Map overrides) { + props.putAll(overrides); + } + + @Override + public long getLongPrpt(String[] props) { + return Long.parseLong(getPrpt(props)); + } + + @Override + public long getLongPrpt(String prop, long def) { + return Long.parseLong(this.props.getOrDefault(prop, Long.toString(def))); + } + + @Override + public String getPrpt(String[] props) { + return this.props.getOrDefault(props[0], props[1]); + } + + @Override + public String getPrpt(String prop) { + return props.get(prop); + } + + @Override + public String getPrpt(String property, String defaultValue) { + return props.getOrDefault(property, defaultValue); + } + + @Override + public int getIntPrpt(String[] props) { + return Integer.parseInt(getPrpt(props)); + } + + @Override + public int getIntPrpt(String s, int intPrpt) { + return Integer.parseInt(props.getOrDefault(s, Integer.toString(intPrpt))); + } + + @Override + public boolean getBlnPrpt(String[] props) { + return Boolean.parseBoolean(getPrpt(props)); + } + + @Override + public boolean getBlnPrpt(String s, boolean defaultValue) { + return Boolean.parseBoolean(props.getOrDefault(s, Boolean.toString(defaultValue))); + } + + @Override + public void loadDefaultParams(String[][] prms) { + for (String[] prm : prms) { + props.putIfAbsent(prm[0], prm[1]); + } + } + + @Override + public void loadDefaultParams(String prefix, String[][] prms) { + for (String[] prm : prms) { + props.putIfAbsent(prefix + "." + prm[0], prm[1]); + } + } + + @Override + public boolean storeProperties(Map props) { + this.props.putAll(props); + return true; + } + + @Override + public void addPropertyChangeListener(PropertyChangeListener propertyChanged) { + } + + @Override + public void removePropertyChangeListener(PropertyChangeListener propertyChangeListener) { + } + + @Override + public Set getAllKeysReadOnly() { + return Set.copyOf(props.keySet()); + } + + @Override + public String setProperty(String s, String aFalse) { + return props.put(s, aFalse); + } + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java index d8ab127..bd2d62f 100644 --- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java @@ -5,8 +5,10 @@ import com.cloudhopper.smpp.SmppSessionConfiguration; import com.cloudhopper.smpp.pdu.SubmitSm; import com.cloudhopper.smpp.pdu.SubmitSmResp; +import com.cloudhopper.smpp.tlv.Tlv; import com.cloudhopper.smpp.type.SmppProcessingException; import gr.cytech.sendium.core.message.StandardMessage; +import gr.cytech.sendium.core.smpp.util.SmppServerUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,4 +81,76 @@ void handleSubmitSm_whenSubmitProcessorThrowsSmppProcessingException_shouldEnque SubmitSmResp capturedResp = respCaptor.getValue(); assertThat(capturedResp.getCommandStatus()).isEqualTo(expectedErrorCode); } -} \ No newline at end of file + + @Test + void validateShortMessage_whenShortMessageMissingAndPayloadMissing_shouldEnqueueInvalidLengthResponse() { + SubmitSm submitSm = new SubmitSm(); + + SmppServerUtil.ValidatedMessageBody result = handler.validateShortMessage(submitSm); + + ArgumentCaptor respCaptor = ArgumentCaptor.forClass(SubmitSmResp.class); + verify(worker).enqueueOut(respCaptor.capture()); + assertThat(result).isNull(); + assertThat(respCaptor.getValue().getCommandStatus()).isEqualTo(SmppConstants.STATUS_INVMSGLEN); + } + + @Test + void validateShortMessage_whenShortMessageMissingUsesMessagePayloadTlv() { + SubmitSm submitSm = new SubmitSm(); + submitSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + submitSm.setOptionalParameter(new Tlv( + SmppConstants.TAG_MESSAGE_PAYLOAD, + "Hello from payload".getBytes(StandardCharsets.UTF_8))); + when(worker.getCharsetGsm()).thenReturn(StandardCharsets.UTF_8.toString()); + + SmppServerUtil.ValidatedMessageBody result = handler.validateShortMessage(submitSm); + + verify(worker, never()).enqueueOut(any()); + assertThat(result.text()).isEqualTo("Hello from payload"); + assertThat(result.udh()).isNull(); + assertThat(result.smType()).isEqualTo(StandardMessage.MSG_TEXT); + } + + @Test + void validateShortMessage_whenDataCodingUnsupported_shouldEnqueueInvalidDcsResponse() throws Exception { + SubmitSm submitSm = new SubmitSm(); + submitSm.setShortMessage("Hello".getBytes(StandardCharsets.UTF_8)); + submitSm.setDataCoding((byte) 0x7F); + + SmppServerUtil.ValidatedMessageBody result = handler.validateShortMessage(submitSm); + + ArgumentCaptor respCaptor = ArgumentCaptor.forClass(SubmitSmResp.class); + verify(worker).enqueueOut(respCaptor.capture()); + assertThat(result).isNull(); + assertThat(respCaptor.getValue().getCommandStatus()).isEqualTo(VendorSpecificConstants.STATUS_RINVDCS); + } + + @Test + void validateShortMessage_whenUdhiSet_shouldStripUdhFromBody() throws Exception { + SubmitSm submitSm = new SubmitSm(); + submitSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT); + submitSm.setEsmClass(SmppConstants.ESM_CLASS_UDHI_MASK); + submitSm.setShortMessage(new byte[]{0x05, 0x00, 0x03, 0x01, 0x02, 0x01, 'H', 'e', 'l', 'l', 'o'}); + when(worker.getCharsetGsm()).thenReturn(StandardCharsets.UTF_8.toString()); + + SmppServerUtil.ValidatedMessageBody result = handler.validateShortMessage(submitSm); + + verify(worker, never()).enqueueOut(any()); + assertThat(result.udh()).isEqualTo("050003010201"); + assertThat(result.text()).isEqualTo("Hello"); + assertThat(result.smType()).isEqualTo(StandardMessage.MSG_TEXT); + } + + @Test + void validateScheduleDeliveryTime_whenInvalid_shouldEnqueueInvalidScheduleResponse() { + SubmitSm submitSm = new SubmitSm(); + submitSm.setScheduleDeliveryTime("not-a-schedule"); + + Timestamp result = handler.validateScheduleDeliveryTime(submitSm); + + ArgumentCaptor respCaptor = ArgumentCaptor.forClass(SubmitSmResp.class); + verify(worker).enqueueOut(respCaptor.capture()); + assertThat(result).isNull(); + assertThat(respCaptor.getValue().getCommandStatus()).isEqualTo(SmppConstants.STATUS_INVSCHED); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/util/SmppServerUtilTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/util/SmppServerUtilTest.java new file mode 100644 index 0000000..5712661 --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/util/SmppServerUtilTest.java @@ -0,0 +1,114 @@ +package gr.cytech.sendium.core.smpp.util; + +import com.cloudhopper.commons.charset.CharsetUtil; +import com.cloudhopper.commons.util.HexUtil; +import com.cloudhopper.smpp.SmppConstants; +import com.cloudhopper.smpp.pdu.GenericNack; +import com.cloudhopper.smpp.pdu.SubmitSm; +import com.cloudhopper.smpp.pdu.SubmitSmResp; +import gr.cytech.sendium.core.message.StandardMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SmppServerUtilTest { + + @Test + void encodeAndDecodeFinalStateMapsKnownStates() { + assertThat(SmppServerUtil.encodeFinalState(StandardMessage.DLR_STAT_DELIVRD)) + .isEqualTo(SmppConstants.STATE_DELIVERED); + assertThat(SmppServerUtil.encodeFinalState(StandardMessage.DLR_STAT_FAILED)) + .isEqualTo(SmppConstants.STATE_UNDELIVERABLE); + assertThat(SmppServerUtil.encodeFinalState(StandardMessage.DLR_STAT_BUFFRED)) + .isEqualTo(SmppConstants.STATE_ENROUTE); + assertThat(SmppServerUtil.decodeFinalState(SmppConstants.STATE_REJECTED)) + .isEqualTo(StandardMessage.DLR_STAT_REJECTD); + assertThat(SmppServerUtil.decodeFinalState((byte) 0x7F)) + .isEqualTo(StandardMessage.DLR_STAT_FAILED); + } + + @Test + void createSubmitRspCopiesStatusMessageIdAndReferenceObject() { + SubmitSm request = new SubmitSm(); + Object reference = new Object(); + request.setReferenceObject(reference); + + SubmitSmResp response = SmppServerUtil.createSubmitRsp(request, SmppConstants.STATUS_THROTTLED, "msg-1"); + + assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_THROTTLED); + assertThat(response.getMessageId()).isEqualTo("msg-1"); + assertThat(response.getReferenceObject()).isSameAs(reference); + } + + @Test + void createSubmitRspRejectsNullRequest() { + assertThatThrownBy(() -> SmppServerUtil.createSubmitRsp(null, SmppConstants.STATUS_OK, "msg-1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void createGenericNackCopiesStatusAndReferenceObject() { + SubmitSm request = new SubmitSm(); + Object reference = new Object(); + request.setReferenceObject(reference); + + GenericNack nack = SmppServerUtil.createGenericNack(request, SmppConstants.STATUS_INVCMDID); + + assertThat(nack.getCommandStatus()).isEqualTo(SmppConstants.STATUS_INVCMDID); + assertThat(nack.getReferenceObject()).isSameAs(reference); + } + + @Test + void splitMessageReturnsSingleSegmentWhenBodyFits() { + byte[][] parts = SmppServerUtil.splitMessage("a".repeat(160), CharsetUtil.NAME_GSM, 0x2A, true); + + assertThat(parts.length).isEqualTo(1); + assertThat(CharsetUtil.decode(parts[0], CharsetUtil.NAME_GSM)).isEqualTo("a".repeat(160)); + } + + @Test + void splitMessageAdds8BitUdhForMultipartGsmMessages() { + byte[][] parts = SmppServerUtil.splitMessage("a".repeat(161), CharsetUtil.NAME_GSM, 0x2A, true); + + assertThat(parts.length).isEqualTo(2); + assertThat(HexUtil.toHexString(parts[0], 0, 6)).isEqualTo("0500032A0201"); + assertThat(HexUtil.toHexString(parts[1], 0, 6)).isEqualTo("0500032A0202"); + assertThat(parts[0]).hasSize(159); + assertThat(parts[1]).hasSize(14); + } + + @Test + void splitMessageAdds16BitUdhWhenConfigured() { + byte[][] parts = SmppServerUtil.splitMessage("a".repeat(161), CharsetUtil.NAME_GSM, 0x1234, false); + + assertThat(parts.length).isEqualTo(2); + assertThat(HexUtil.toHexString(parts[0], 0, 7)).isEqualTo("06080412340201"); + assertThat(HexUtil.toHexString(parts[1], 0, 7)).isEqualTo("06080412340202"); + } + + @Test + void splitMessageToBodyAndUdhSeparatesHeaderFromPayload() { + byte[] data = new byte[]{0x05, 0x00, 0x03, 0x01, 0x02, 0x01, 'H', 'i'}; + + byte[][] split = SmppServerUtil.splitMessageToBodyAndUdh(data); + + assertThat(HexUtil.toHexString(split[1])).isEqualTo("050003010201"); + assertThat(new String(split[0])).isEqualTo("Hi"); + } + + @Test + void mergeByteArraysAppendsSecondArray() { + byte[] merged = SmppServerUtil.mergeByteArrays(new byte[]{0x01, 0x02}, new byte[]{0x03}); + + assertThat(merged).containsExactly((byte) 0x01, (byte) 0x02, (byte) 0x03); + } + + @Test + void encodeErrorCodeFallsBackForUnknownErrors() { + assertThat(SmppServerUtil.encodeErrorCode(StandardMessage.DLR_ERR_SUCCESS)) + .isEqualTo(StandardMessage.DLR_ERR_SUCCESS); + assertThat(SmppServerUtil.encodeErrorCode(9999)) + .isEqualTo(StandardMessage.DLR_ERR_UNKNOWN_ERROR); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/routing/RoutingFileWatcherTest.java b/sendium-core/src/test/java/gr/cytech/sendium/routing/RoutingFileWatcherTest.java new file mode 100644 index 0000000..7fbd95e --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/routing/RoutingFileWatcherTest.java @@ -0,0 +1,110 @@ +package gr.cytech.sendium.routing; + +import gr.cytech.sendium.conf.SendiumConfigurationHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RoutingFileWatcherTest { + + @TempDir Path tempDir; + + private RoutingFileWatcher watcher; + + private ArrayList> notifications; + + @BeforeEach + void setUp() { + watcher = new RoutingFileWatcher(); + SendiumConfigurationHandler configHandler = new SendiumConfigurationHandler(); + configHandler.memoryConfiguration = new ConcurrentHashMap<>(); + configHandler.defaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.overriddenDefaultsConfiguration = new ConcurrentHashMap<>(); + configHandler.currentStoreConfiguration = new ConcurrentHashMap<>(); + configHandler.listeners = new CopyOnWriteArraySet<>(); + watcher.configHandler = configHandler; + notifications = new ArrayList<>(); + } + + @Test + void reloadRoutingConfigurationParsesFileAndNotifiesListeners() throws Exception { + Path file = tempDir.resolve("routing.conf"); + Files.writeString(file, "[default]\nworker:type:==:0\n"); + watcher.addRoutingChangeListener(notifications::add); + + reload(file.toFile()); + + assertThat(watcher.getUpdatedRoutes()).containsKey(RoutingFileParser.DEFAULT_ROUTING_TABLE_NAME); + assertThat(watcher.getUpdatedRoutes().get(RoutingFileParser.DEFAULT_ROUTING_TABLE_NAME).rules).hasSize(1); + assertThat(notifications).hasSize(1); + assertThat(notifications.getFirst()).containsKey(RoutingFileParser.DEFAULT_ROUTING_TABLE_NAME); + } + + @Test + void reloadRoutingConfigurationContinuesWhenListenerThrows() throws Exception { + Path file = tempDir.resolve("routing.conf"); + Files.writeString(file, "[default]\nworker:type:==:0\n"); + watcher.addRoutingChangeListener(table -> { + throw new RuntimeException("boom"); + }); + watcher.addRoutingChangeListener(notifications::add); + + reload(file.toFile()); + + assertThat(notifications).hasSize(1); + } + + @Test + void reloadRoutingConfigurationRetainsPreviousRoutesWhenReloadFails() throws Exception { + Path file = tempDir.resolve("routing.conf"); + Files.writeString(file, "[default]\nworker:type:==:0\n"); + reload(file.toFile()); + Map previous = watcher.getUpdatedRoutes(); + + reload(tempDir.toFile()); + + assertThat(watcher.getUpdatedRoutes()).isEqualTo(previous); + } + + @Test + void getUpdatedRoutesReturnsEmptyBeforeFirstLoadAndUnmodifiableAfterLoad() throws Exception { + assertThat(watcher.getUpdatedRoutes()).isEmpty(); + Path file = tempDir.resolve("routing.conf"); + Files.writeString(file, "[default]\nworker:type:==:0\n"); + reload(file.toFile()); + + assertThatThrownBy(() -> watcher.getUpdatedRoutes().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void removeRoutingChangeListenerStopsNotifications() throws Exception { + Path file = tempDir.resolve("routing.conf"); + Files.writeString(file, "[default]\nworker:type:==:0\n"); + RoutingChangeListener listener = notifications::add; + watcher.addRoutingChangeListener(listener); + watcher.removeRoutingChangeListener(listener); + + reload(file.toFile()); + + assertThat(notifications).isEmpty(); + } + + private void reload(File file) throws Exception { + Method method = RoutingFileWatcher.class.getDeclaredMethod("reloadRoutingConfiguration", File.class); + method.setAccessible(true); + method.invoke(watcher, file); + } +} diff --git a/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java b/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java new file mode 100644 index 0000000..d1545ee --- /dev/null +++ b/sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java @@ -0,0 +1,106 @@ +package gr.cytech.sendium.util; + +import gr.cytech.sendium.core.message.StandardMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessageUtilTest { + + @Test + void udhHelpersIdentify8BitConcatenatedMessageParts() { + StandardMessage message = messageWithUdh("0500037F0201"); + message.owner_id = "account-a"; + + assertThat(MessageUtil.hasUdh(message)).isTrue(); + assertThat(MessageUtil.is8BitMessagePart(message)).isTrue(); + assertThat(MessageUtil.is16BitMessagePart(message)).isFalse(); + assertThat(MessageUtil.getMessageReference(message)).isEqualTo("account-a:7F"); + assertThat(MessageUtil.getNumberOfTotalParts(message)).isEqualTo(2); + assertThat(MessageUtil.getNumberOfCurrentPart(message)).isEqualTo(1); + } + + @Test + void udhHelpersIdentify16BitConcatenatedMessageParts() { + StandardMessage message = messageWithUdh("06080412340302"); + message.owner_id = "account-b"; + + assertThat(MessageUtil.is16BitMessagePart(message)).isTrue(); + assertThat(MessageUtil.is8BitMessagePart(message)).isFalse(); + assertThat(MessageUtil.getMessageReference(message)).isEqualTo("account-b:1234"); + assertThat(MessageUtil.getNumberOfTotalParts(message)).isEqualTo(3); + assertThat(MessageUtil.getNumberOfCurrentPart(message)).isEqualTo(2); + } + + @Test + void udhHelpersReturnDefaultsWhenNoSupportedUdhExists() { + StandardMessage message = new StandardMessage(); + message.owner_id = "account"; + + assertThat(MessageUtil.hasUdh(message)).isFalse(); + assertThat(MessageUtil.getMessageReference(message)).isNull(); + assertThat(MessageUtil.getNumberOfTotalParts(message)).isZero(); + assertThat(MessageUtil.getNumberOfCurrentPart(message)).isZero(); + } + + @Test + void getSmsCntUsesTextAndConcatenationBoundaries() { + assertThat(MessageUtil.getSmsCnt("a".repeat(160), StandardMessage.MSG_TEXT, true)).isEqualTo(1); + assertThat(MessageUtil.getSmsCnt("a".repeat(161), StandardMessage.MSG_TEXT, true)).isEqualTo(2); + assertThat(MessageUtil.getSmsCnt("a".repeat(306), StandardMessage.MSG_TEXT, true)).isEqualTo(2); + assertThat(MessageUtil.getSmsCnt("a".repeat(307), StandardMessage.MSG_TEXT, true)).isEqualTo(3); + } + + @Test + void getSmsCntUsesUcs2AndPushBoundaries() { + assertThat(MessageUtil.getSmsCnt("a".repeat(70), StandardMessage.MSG_UCS2, true)).isEqualTo(1); + assertThat(MessageUtil.getSmsCnt("a".repeat(71), StandardMessage.MSG_UCS2, true)).isEqualTo(2); + assertThat(MessageUtil.getSmsCnt("a".repeat(134), StandardMessage.MSG_UCS2, true)).isEqualTo(2); + assertThat(MessageUtil.getSmsCnt("a".repeat(268), StandardMessage.MSG_PUSH, false)).isEqualTo(-1); + assertThat(MessageUtil.getSmsCnt("a".repeat(268), StandardMessage.MSG_PUSH, true)).isEqualTo(2); + } + + @Test + void getSmsCntAndTrimForUdhTrimsTextToSingleSmsCapacity() { + StandardMessage message = messageWithUdh("050003010201"); + message.type = StandardMessage.MSG_TEXT; + message.body = "a".repeat(160); + + int smsCount = MessageUtil.getSmsCntAndTrimForUdh(message, true); + + assertThat(smsCount).isEqualTo(1); + assertThat(message.body).hasSize(153); + } + + @Test + void getSmsCntAndTrimForUdhTrimsBinaryHexBodyToSingleSmsCapacity() { + StandardMessage message = messageWithUdh("050003010201"); + message.type = StandardMessage.MSG_BINARY; + message.body = "A".repeat(300); + + int smsCount = MessageUtil.getSmsCntAndTrimForUdh(message, true); + + assertThat(smsCount).isEqualTo(1); + assertThat(message.body).hasSize(268); + } + + @Test + void validateSmsLengthCountsEscapedGsmCharactersWhenTrimming() { + String result = MessageUtil.validateSmsLength(null, "{}A", StandardMessage.MSG_TEXT, 3); + + assertThat(result).isEqualTo("{"); + } + + @Test + void validateTextNormalizesGreekForTextMessages() { + String result = MessageUtil.validateText(null, "άβ", StandardMessage.MSG_TEXT, 160); + + assertThat(result).isEqualTo("ΑΒ"); + } + + private StandardMessage messageWithUdh(String udh) { + StandardMessage message = new StandardMessage(); + message.binheader = udh; + return message; + } +}