From 5200f31ea1b9f157c471b875a10998aeaf9898ec Mon Sep 17 00:00:00 2001 From: pavlos Date: Wed, 27 May 2026 10:57:00 +0300 Subject: [PATCH] test(coverage): Improved test coverage Cover SMPP server inbound validation. Cover SMPP client delivery and DLR paths Expand HTTP API integration coverage Cover queue and message utility boundaries Cover configuration and watcher behavior Signed-off-by: pavlos --- .../sendium/core/http/KannelResource.java | 7 +- .../auth/CredentialFileWatcherTest.java | 150 +++++++ .../sendium/conf/ConfFileWatcherTest.java | 104 +++++ .../conf/SendiumConfigurationHandlerTest.java | 116 +++++ .../sendium/core/http/KannelResourceIT.java | 106 ++++- .../cytech/sendium/core/queue/QueueTest.java | 118 ++++++ .../client/SmppClientSessionHandlerTest.java | 145 +++++++ .../smpp/client/SmppClientWorkerTest.java | 400 ++++++++++++++++++ .../server/SmppServerSessionHandlerTest.java | 77 +++- .../core/smpp/util/SmppServerUtilTest.java | 114 +++++ .../routing/RoutingFileWatcherTest.java | 110 +++++ .../cytech/sendium/util/MessageUtilTest.java | 106 +++++ 12 files changed, 1548 insertions(+), 5 deletions(-) create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/auth/CredentialFileWatcherTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/conf/ConfFileWatcherTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/conf/SendiumConfigurationHandlerTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/core/queue/QueueTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientSessionHandlerTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/core/smpp/util/SmppServerUtilTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/routing/RoutingFileWatcherTest.java create mode 100644 sendium-core/src/test/java/gr/cytech/sendium/util/MessageUtilTest.java 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; + } +}