diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/AckCallbackTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/AckCallbackTest.java new file mode 100644 index 00000000..a445da73 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/AckCallbackTest.java @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@DisplayName("Ack callback Tests") +class AckCallbackTest { + + @Nested + @DisplayName("AckCallback Tests") + class AckCallbackTests { + + @Test + @DisplayName("Should default to no timeout and expose the result class") + void shouldDefaultToNoTimeout() { + AckCallback callback = new AckCallback(String.class) { + @Override + public void onSuccess(String result) { + } + }; + + assertThat(callback.getTimeout()).isEqualTo(-1); + assertThat(callback.getResultClass()).isEqualTo(String.class); + } + + @Test + @DisplayName("Should keep the configured timeout and ignore onTimeout by default") + void shouldKeepConfiguredTimeout() { + AckCallback callback = new AckCallback(String.class, 30) { + @Override + public void onSuccess(String result) { + } + }; + + callback.onTimeout(); + + assertThat(callback.getTimeout()).isEqualTo(30); + } + } + + @Nested + @DisplayName("VoidAckCallback Tests") + class VoidAckCallbackTests { + + @Test + @DisplayName("Should delegate onSuccess to the no argument variant") + void shouldDelegateOnSuccess() { + AtomicInteger invocations = new AtomicInteger(); + VoidAckCallback callback = new VoidAckCallback() { + @Override + protected void onSuccess() { + invocations.incrementAndGet(); + } + }; + + callback.onSuccess(null); + + assertThat(invocations.get()).isEqualTo(1); + assertThat(callback.getResultClass()).isEqualTo(Void.class); + assertThat(callback.getTimeout()).isEqualTo(-1); + } + + @Test + @DisplayName("Should keep the configured timeout") + void shouldKeepConfiguredTimeout() { + VoidAckCallback callback = new VoidAckCallback(15) { + @Override + protected void onSuccess() { + } + }; + + assertThat(callback.getTimeout()).isEqualTo(15); + } + } + + @Nested + @DisplayName("MultiTypeAckCallback Tests") + class MultiTypeAckCallbackTests { + + @Test + @DisplayName("Should expose the argument classes") + void shouldExposeResultClasses() { + MultiTypeAckCallback callback = new MultiTypeAckCallback(String.class, Integer.class) { + @Override + public void onSuccess(MultiTypeArgs result) { + } + }; + + assertThat(callback.getResultClasses()).containsExactly(String.class, Integer.class); + assertThat(callback.getResultClass()).isEqualTo(MultiTypeArgs.class); + } + } + + @Nested + @DisplayName("MultiTypeArgs Tests") + class MultiTypeArgsTests { + + @Test + @DisplayName("Should expose size, emptiness and the backing list") + void shouldExposeSizeAndArgs() { + List args = new ArrayList<>(); + args.add("first"); + args.add(2); + MultiTypeArgs multiTypeArgs = new MultiTypeArgs(args); + + assertThat(multiTypeArgs.size()).isEqualTo(2); + assertThat(multiTypeArgs.isEmpty()).isFalse(); + assertThat(multiTypeArgs.getArgs()).isSameAs(args); + assertThat(multiTypeArgs).containsExactly("first", 2); + } + + @Test + @DisplayName("Should be empty for an empty argument list") + void shouldBeEmptyForEmptyList() { + MultiTypeArgs multiTypeArgs = new MultiTypeArgs(new ArrayList<>()); + + assertThat(multiTypeArgs.isEmpty()).isTrue(); + assertThat(multiTypeArgs.size()).isZero(); + } + + @Test + @DisplayName("Should return null instead of throwing for out of bounds indexes") + void shouldReturnNullForOutOfBoundsIndex() { + List args = new ArrayList<>(); + args.add("only"); + MultiTypeArgs multiTypeArgs = new MultiTypeArgs(args); + + assertThat(multiTypeArgs.first()).isEqualTo("only"); + assertThat(multiTypeArgs.second()).isNull(); + assertThat(multiTypeArgs.get(10)).isNull(); + } + } + + @Nested + @DisplayName("BroadcastAckCallback Tests") + class BroadcastAckCallbackTests { + + @Test + @DisplayName("Should notify all success once every client acknowledged after the loop finished") + void shouldNotifyAllSuccessAfterLoopFinished() { + List successClients = new ArrayList<>(); + AtomicInteger allSuccessInvocations = new AtomicInteger(); + BroadcastAckCallback callback = new BroadcastAckCallback(String.class) { + @Override + protected void onClientSuccess(SocketIOClient client, String result) { + successClients.add(client); + } + + @Override + protected void onAllSuccess() { + allSuccessInvocations.incrementAndGet(); + } + }; + + SocketIOClient firstClient = mock(SocketIOClient.class); + SocketIOClient secondClient = mock(SocketIOClient.class); + AckCallback first = callback.createClientCallback(firstClient); + AckCallback second = callback.createClientCallback(secondClient); + + first.onSuccess("one"); + assertThat(allSuccessInvocations.get()).isZero(); + + callback.loopFinished(); + assertThat(allSuccessInvocations.get()).isZero(); + + second.onSuccess("two"); + + assertThat(successClients).containsExactly(firstClient, secondClient); + assertThat(allSuccessInvocations.get()).isEqualTo(1); + } + + @Test + @DisplayName("Should notify all success immediately when there is no client to wait for") + void shouldNotifyAllSuccessWithoutClients() { + AtomicInteger allSuccessInvocations = new AtomicInteger(); + BroadcastAckCallback callback = new BroadcastAckCallback(String.class, 10) { + @Override + protected void onAllSuccess() { + allSuccessInvocations.incrementAndGet(); + } + }; + + callback.loopFinished(); + callback.loopFinished(); + + assertThat(allSuccessInvocations.get()).isEqualTo(1); + } + + @Test + @DisplayName("Should propagate the timeout to the client callbacks") + void shouldPropagateTimeoutToClientCallbacks() { + List timedOutClients = new ArrayList<>(); + BroadcastAckCallback callback = new BroadcastAckCallback(String.class, 25) { + @Override + protected void onClientTimeout(SocketIOClient client) { + timedOutClients.add(client); + } + }; + + SocketIOClient client = mock(SocketIOClient.class); + AckCallback clientCallback = callback.createClientCallback(client); + clientCallback.onTimeout(); + + assertThat(clientCallback.getTimeout()).isEqualTo(25); + assertThat(clientCallback.getResultClass()).isEqualTo(String.class); + assertThat(timedOutClients).containsExactly(client); + } + + @Test + @DisplayName("Should not notify all success while a client ack is still pending") + void shouldNotNotifyAllSuccessWhileAckPending() { + AtomicInteger allSuccessInvocations = new AtomicInteger(); + BroadcastAckCallback callback = new BroadcastAckCallback(String.class) { + @Override + protected void onAllSuccess() { + allSuccessInvocations.incrementAndGet(); + } + }; + + callback.createClientCallback(mock(SocketIOClient.class)); + callback.loopFinished(); + + assertThat(allSuccessInvocations.get()).isZero(); + } + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/HandshakeDataTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/HandshakeDataTest.java new file mode 100644 index 00000000..024bbeca --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/HandshakeDataTest.java @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio; + +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("HandshakeData Tests") +class HandshakeDataTest { + + private static final InetSocketAddress REMOTE = InetSocketAddress.createUnresolved("10.0.0.1", 5555); + private static final InetSocketAddress LOCAL = InetSocketAddress.createUnresolved("127.0.0.1", 8080); + + private static HandshakeData handshakeData(Map> urlParams) { + HttpHeaders headers = new DefaultHttpHeaders().add("Origin", "http://localhost"); + return new HandshakeData(headers, urlParams, REMOTE, LOCAL, "/socket.io/?EIO=4", true); + } + + @Test + @DisplayName("Should expose all handshake attributes") + void shouldExposeAllAttributes() { + Map> urlParams = new HashMap<>(); + urlParams.put("EIO", Collections.singletonList("4")); + HandshakeData data = handshakeData(urlParams); + + assertThat(data.getAddress()).isEqualTo(REMOTE); + assertThat(data.getLocal()).isEqualTo(LOCAL); + assertThat(data.getUrl()).isEqualTo("/socket.io/?EIO=4"); + assertThat(data.isXdomain()).isTrue(); + assertThat(data.getUrlParams()).isEqualTo(urlParams); + assertThat(data.getHttpHeaders().get("Origin")).isEqualTo("http://localhost"); + assertThat(data.getTime()).isNotNull(); + } + + @Test + @DisplayName("Should leave the local address unset when it is not provided") + void shouldLeaveLocalAddressUnset() { + HandshakeData data = new HandshakeData(new DefaultHttpHeaders(), Collections.emptyMap(), + REMOTE, "/socket.io/", false); + + assertThat(data.getLocal()).isNull(); + assertThat(data.isXdomain()).isFalse(); + } + + @Test + @DisplayName("Should return a single url param value only when it is unambiguous") + void shouldReturnSingleUrlParam() { + Map> urlParams = new HashMap<>(); + urlParams.put("single", Collections.singletonList("value")); + urlParams.put("multiple", Arrays.asList("first", "second")); + urlParams.put("empty", Collections.emptyList()); + HandshakeData data = handshakeData(urlParams); + + assertThat(data.getSingleUrlParam("single")).isEqualTo("value"); + assertThat(data.getSingleUrlParam("multiple")).isNull(); + assertThat(data.getSingleUrlParam("empty")).isNull(); + assertThat(data.getSingleUrlParam("unknown")).isNull(); + } + + @Test + @DisplayName("Should store the auth token") + void shouldStoreAuthToken() { + HandshakeData data = handshakeData(Collections.emptyMap()); + + assertThat(data.getAuthToken()).isNull(); + data.setAuthToken("token"); + + assertThat(data.getAuthToken()).isEqualTo("token"); + } + + @Test + @DisplayName("Should provide a no argument constructor for deserialization") + void shouldProvideNoArgConstructor() { + HandshakeData data = new HandshakeData(); + + assertThat(data.getTime()).isNotNull(); + assertThat(data.getAddress()).isNull(); + assertThat(data.getUrl()).isNull(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/JsonSupportWrapperTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/JsonSupportWrapperTest.java new file mode 100644 index 00000000..39d59341 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/JsonSupportWrapperTest.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.socketio4j.socketio.protocol.AckArgs; +import com.socketio4j.socketio.protocol.JsonSupport; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.Unpooled; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("JsonSupportWrapper Tests") +class JsonSupportWrapperTest { + + private JsonSupport delegate; + private JsonSupportWrapper wrapper; + private ByteBuf buffer; + + private static final AckCallback CALLBACK = new AckCallback(String.class) { + @Override + public void onSuccess(String result) { + } + }; + + @BeforeEach + void setUp() { + delegate = mock(JsonSupport.class); + wrapper = new JsonSupportWrapper(delegate); + buffer = Unpooled.copiedBuffer("{\"a\":1}", StandardCharsets.UTF_8); + } + + @AfterEach + void tearDown() { + buffer.release(); + } + + private ByteBufInputStream inputStream() { + ByteBufInputStream in = new ByteBufInputStream(buffer); + in.mark(buffer.readableBytes()); + return in; + } + + @Test + @DisplayName("Should delegate ack args reading") + void shouldDelegateReadAckArgs() throws IOException { + AckArgs expected = new AckArgs(Collections.singletonList("value")); + ByteBufInputStream in = inputStream(); + when(delegate.readAckArgs(in, CALLBACK)).thenReturn(expected); + + assertThat(wrapper.readAckArgs(in, CALLBACK)).isSameAs(expected); + } + + @Test + @DisplayName("Should wrap ack args reading failures into an IOException") + void shouldWrapReadAckArgsFailure() throws IOException { + ByteBufInputStream in = inputStream(); + when(delegate.readAckArgs(any(), any())).thenThrow(new IllegalStateException("broken")); + + assertThatThrownBy(() -> wrapper.readAckArgs(in, CALLBACK)) + .isInstanceOf(IOException.class) + .hasCauseInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("Should delegate value reading") + void shouldDelegateReadValue() throws IOException { + ByteBufInputStream in = inputStream(); + when(delegate.readValue("ns", in, String.class)).thenReturn("value"); + + assertThat(wrapper.readValue("ns", in, String.class)).isEqualTo("value"); + } + + @Test + @DisplayName("Should wrap value reading failures into an IOException") + void shouldWrapReadValueFailure() throws IOException { + ByteBufInputStream in = inputStream(); + when(delegate.readValue(eq("ns"), any(), eq(String.class))) + .thenThrow(new IllegalArgumentException("broken")); + + assertThatThrownBy(() -> wrapper.readValue("ns", in, String.class)) + .isInstanceOf(IOException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Should delegate value writing") + void shouldDelegateWriteValue() throws IOException { + ByteBufOutputStream out = new ByteBufOutputStream(buffer); + + wrapper.writeValue(out, "value"); + + verify(delegate).writeValue(out, "value"); + } + + @Test + @DisplayName("Should wrap value writing failures into an IOException") + void shouldWrapWriteValueFailure() throws IOException { + ByteBufOutputStream out = new ByteBufOutputStream(buffer); + doThrow(new IllegalStateException("broken")).when(delegate).writeValue(out, "value"); + + assertThatThrownBy(() -> wrapper.writeValue(out, "value")) + .isInstanceOf(IOException.class) + .hasCauseInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("Should delegate event mapping and array access without wrapping") + void shouldDelegateRemainingCalls() { + List arrays = Collections.singletonList(new byte[]{1, 2}); + when(delegate.getArrays()).thenReturn(arrays); + + wrapper.addEventMapping("ns", "event", String.class); + wrapper.removeEventMapping("ns", "event"); + + assertThat(wrapper.getArrays()).isSameAs(arrays); + verify(delegate).addEventMapping("ns", "event", String.class); + verify(delegate).removeEventMapping("ns", "event"); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckManagerTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckManagerTest.java new file mode 100644 index 00000000..d6211ae8 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckManagerTest.java @@ -0,0 +1,304 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.ack; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.socketio4j.socketio.AckCallback; +import com.socketio4j.socketio.MultiTypeAckCallback; +import com.socketio4j.socketio.MultiTypeArgs; +import com.socketio4j.socketio.SocketIOClient; +import com.socketio4j.socketio.handler.ClientHead; +import com.socketio4j.socketio.protocol.Packet; +import com.socketio4j.socketio.protocol.PacketType; +import com.socketio4j.socketio.scheduler.CancelableScheduler; +import com.socketio4j.socketio.scheduler.SchedulerKey; + +import io.netty.channel.ChannelHandlerContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("AckManager Tests") +class AckManagerTest { + + /** + * Scheduler recording every interaction and allowing manual firing of + * scheduled callbacks. + */ + private static class RecordingScheduler implements CancelableScheduler { + + private final List scheduled = new ArrayList<>(); + private final List cancelled = new ArrayList<>(); + private final List callbacks = new ArrayList<>(); + + @Override + public void update(ChannelHandlerContext ctx) { + } + + @Override + public void cancel(SchedulerKey key) { + cancelled.add(key); + } + + @Override + public void scheduleCallback(SchedulerKey key, Runnable runnable, long delay, TimeUnit unit) { + scheduled.add(key); + callbacks.add(runnable); + } + + @Override + public void schedule(Runnable runnable, long delay, TimeUnit unit) { + } + + @Override + public void schedule(SchedulerKey key, Runnable runnable, long delay, TimeUnit unit) { + } + + @Override + public void shutdown() { + } + + void fireAll() { + for (Runnable callback : new ArrayList<>(callbacks)) { + callback.run(); + } + } + } + + private RecordingScheduler scheduler; + private AckManager ackManager; + private UUID sessionId; + + @BeforeEach + void setUp() { + scheduler = new RecordingScheduler(); + ackManager = new AckManager(scheduler); + sessionId = UUID.randomUUID(); + } + + private static Packet ackPacket(long ackId, List data) { + Packet packet = new Packet(PacketType.MESSAGE); + packet.setSubType(PacketType.ACK); + packet.setAckId(ackId); + packet.setData(data); + return packet; + } + + private SocketIOClient client() { + SocketIOClient client = mock(SocketIOClient.class); + when(client.getSessionId()).thenReturn(sessionId); + return client; + } + + private static AckCallback callback(AtomicReference result, int timeout) { + return new AckCallback(String.class, timeout) { + @Override + public void onSuccess(String value) { + result.set(value); + } + }; + } + + @Test + @DisplayName("Should register callbacks with incrementing indexes starting from 1") + void shouldRegisterCallbacksWithIncrementingIndexes() { + AtomicReference result = new AtomicReference<>(); + + assertThat(ackManager.registerAck(sessionId, callback(result, -1))).isEqualTo(1); + assertThat(ackManager.registerAck(sessionId, callback(result, -1))).isEqualTo(2); + } + + @Test + @DisplayName("Should return registered callback by index and null for unknown index") + void shouldReturnRegisteredCallback() { + AtomicReference result = new AtomicReference<>(); + AckCallback callback = callback(result, -1); + + long index = ackManager.registerAck(sessionId, callback); + + assertThat(ackManager.getCallback(sessionId, index)).isSameAs(callback); + assertThat(ackManager.getCallback(sessionId, index + 1)).isNull(); + assertThat(ackManager.getCallback(UUID.randomUUID(), index)).isNull(); + } + + @Test + @DisplayName("Should start ack index at the initialized value") + void shouldStartAckIndexAtInitializedValue() { + ackManager.initAckIndex(sessionId, 5); + + AtomicReference result = new AtomicReference<>(); + assertThat(ackManager.registerAck(sessionId, callback(result, -1))).isEqualTo(6); + } + + @Test + @DisplayName("Should keep the first initialized ack index") + void shouldKeepFirstInitializedAckIndex() { + ackManager.initAckIndex(sessionId, 5); + ackManager.initAckIndex(sessionId, 100); + + AtomicReference result = new AtomicReference<>(); + assertThat(ackManager.registerAck(sessionId, callback(result, -1))).isEqualTo(6); + } + + @Test + @DisplayName("Should not schedule a timeout for callbacks without timeout") + void shouldNotScheduleTimeoutWhenTimeoutIsNotSet() { + ackManager.registerAck(sessionId, callback(new AtomicReference<>(), -1)); + + assertThat(scheduler.scheduled).isEmpty(); + } + + @Test + @DisplayName("Should schedule an ack timeout for callbacks with timeout") + void shouldScheduleTimeoutWhenTimeoutIsSet() { + long index = ackManager.registerAck(sessionId, callback(new AtomicReference<>(), 10)); + + assertThat(scheduler.scheduled) + .containsExactly(new AckSchedulerKey(SchedulerKey.Type.ACK_TIMEOUT, sessionId, index)); + } + + @Test + @DisplayName("Should pass the first ack argument to a single type callback") + void shouldPassFirstArgumentToCallback() { + AtomicReference result = new AtomicReference<>(); + long index = ackManager.registerAck(sessionId, callback(result, -1)); + + ackManager.onAck(client(), ackPacket(index, Collections.singletonList("data"))); + + assertThat(result.get()).isEqualTo("data"); + assertThat(ackManager.getCallback(sessionId, index)).isNull(); + } + + @Test + @DisplayName("Should pass null to a single type callback when ack has no arguments") + void shouldPassNullWhenAckHasNoArguments() { + AtomicReference result = new AtomicReference<>("unset"); + long index = ackManager.registerAck(sessionId, callback(result, -1)); + + ackManager.onAck(client(), ackPacket(index, Collections.emptyList())); + + assertThat(result.get()).isNull(); + } + + @Test + @DisplayName("Should pass all ack arguments to a multi type callback") + void shouldPassAllArgumentsToMultiTypeCallback() { + AtomicReference result = new AtomicReference<>(); + MultiTypeAckCallback callback = new MultiTypeAckCallback(String.class, Integer.class) { + @Override + public void onSuccess(MultiTypeArgs args) { + result.set(args); + } + }; + long index = ackManager.registerAck(sessionId, callback); + + ackManager.onAck(client(), ackPacket(index, Arrays.asList("first", 2))); + + assertThat(result.get().getArgs()).containsExactly("first", 2); + } + + @Test + @DisplayName("Should cancel the scheduled timeout when the ack is received") + void shouldCancelTimeoutOnAck() { + long index = ackManager.registerAck(sessionId, callback(new AtomicReference<>(), 10)); + + ackManager.onAck(client(), ackPacket(index, Collections.singletonList("data"))); + + assertThat(scheduler.cancelled) + .containsExactly(new AckSchedulerKey(SchedulerKey.Type.ACK_TIMEOUT, sessionId, index)); + } + + @Test + @DisplayName("Should ignore acks without a registered callback") + void shouldIgnoreUnknownAck() { + ackManager.onAck(client(), ackPacket(42, Collections.singletonList("data"))); + + assertThat(scheduler.cancelled).hasSize(1); + } + + @Test + @DisplayName("Should invoke onTimeout only once when the scheduled timeout fires") + void shouldInvokeTimeoutCallbackOnce() { + AtomicReference timeouts = new AtomicReference<>(0); + AckCallback callback = new AckCallback(String.class, 1) { + @Override + public void onSuccess(String result) { + } + + @Override + public void onTimeout() { + timeouts.set(timeouts.get() + 1); + } + }; + long index = ackManager.registerAck(sessionId, callback); + + scheduler.fireAll(); + scheduler.fireAll(); + + assertThat(timeouts.get()).isEqualTo(1); + assertThat(ackManager.getCallback(sessionId, index)).isNull(); + } + + @Test + @DisplayName("Should time out pending callbacks on disconnect") + void shouldTimeoutPendingCallbacksOnDisconnect() { + AtomicReference timedOut = new AtomicReference<>(false); + AckCallback callback = new AckCallback(String.class, 10) { + @Override + public void onSuccess(String result) { + } + + @Override + public void onTimeout() { + timedOut.set(true); + } + }; + long index = ackManager.registerAck(sessionId, callback); + + ClientHead clientHead = mock(ClientHead.class); + when(clientHead.getSessionId()).thenReturn(sessionId); + ackManager.onDisconnect(clientHead); + + assertThat(timedOut.get()).isTrue(); + assertThat(scheduler.cancelled) + .contains(new AckSchedulerKey(SchedulerKey.Type.ACK_TIMEOUT, sessionId, index)); + assertThat(ackManager.getCallback(sessionId, index)).isNull(); + } + + @Test + @DisplayName("Should do nothing on disconnect of an unknown client") + void shouldDoNothingOnDisconnectOfUnknownClient() { + ClientHead clientHead = mock(ClientHead.class); + when(clientHead.getSessionId()).thenReturn(UUID.randomUUID()); + + ackManager.onDisconnect(clientHead); + + assertThat(scheduler.cancelled).isEmpty(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckSchedulerKeyTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckSchedulerKeyTest.java new file mode 100644 index 00000000..21463c7f --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/ack/AckSchedulerKeyTest.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.ack; + +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.socketio4j.socketio.scheduler.SchedulerKey; +import com.socketio4j.socketio.scheduler.SchedulerKey.Type; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AckSchedulerKey Tests") +class AckSchedulerKeyTest { + + private static final UUID SESSION_ID = UUID.randomUUID(); + + @Test + @DisplayName("Should expose the ack index") + void shouldExposeIndex() { + assertThat(new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 7).getIndex()).isEqualTo(7); + } + + @Test + @DisplayName("Should be equal for the same type, session and index") + void shouldBeEqualForSameValues() { + AckSchedulerKey key = new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 7); + AckSchedulerKey same = new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 7); + + assertThat(key).isEqualTo(key) + .isEqualTo(same) + .hasSameHashCodeAs(same); + } + + @Test + @DisplayName("Should not be equal when type, session or index differ") + void shouldNotBeEqualForDifferentValues() { + AckSchedulerKey key = new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 7); + + assertThat(key) + .isNotEqualTo(new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 8)) + .isNotEqualTo(new AckSchedulerKey(Type.PING_TIMEOUT, SESSION_ID, 7)) + .isNotEqualTo(new AckSchedulerKey(Type.ACK_TIMEOUT, UUID.randomUUID(), 7)); + assertThat(key.hashCode()) + .isNotEqualTo(new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 8).hashCode()); + } + + @Test + @DisplayName("Should not be equal to null, other types or the plain scheduler key") + void shouldNotBeEqualToOtherTypes() { + AckSchedulerKey key = new AckSchedulerKey(Type.ACK_TIMEOUT, SESSION_ID, 7); + + assertThat(key) + .isNotEqualTo(null) + .isNotEqualTo("key") + .isNotEqualTo(new SchedulerKey(Type.ACK_TIMEOUT, SESSION_ID)); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/WrongUrlHandlerTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/WrongUrlHandlerTest.java new file mode 100644 index 00000000..fa540536 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/handler/WrongUrlHandlerTest.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.handler; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("WrongUrlHandler Tests") +class WrongUrlHandlerTest { + + @Test + @DisplayName("Should answer BAD REQUEST, release the request and close the channel") + void shouldRejectHttpRequest() { + EmbeddedChannel channel = new EmbeddedChannel(new WrongUrlHandler()); + FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, + "/wrong/?key=value"); + + channel.writeInbound(request); + + HttpResponse response = channel.readOutbound(); + assertThat(response.status()).isEqualTo(HttpResponseStatus.BAD_REQUEST); + assertThat(request.refCnt()).isZero(); + assertThat(channel.isOpen()).isFalse(); + assertThat((Object) channel.readInbound()).isNull(); + } + + @Test + @DisplayName("Should pass through messages which are not http requests") + void shouldPassThroughNonHttpMessages() { + EmbeddedChannel channel = new EmbeddedChannel(new WrongUrlHandler()); + + channel.writeInbound("payload"); + + assertThat((Object) channel.readOutbound()).isNull(); + assertThat((String) channel.readInbound()).isEqualTo("payload"); + assertThat(channel.isOpen()).isTrue(); + channel.finishAndReleaseAll(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/listener/ExceptionListenerTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/listener/ExceptionListenerTest.java new file mode 100644 index 00000000..26b69cb3 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/listener/ExceptionListenerTest.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.listener; + +import java.util.Collections; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.socketio4j.socketio.SocketIOClient; + +import io.netty.channel.ChannelHandlerContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +@DisplayName("ExceptionListener Tests") +class ExceptionListenerTest { + + private final SocketIOClient client = mock(SocketIOClient.class); + private final ChannelHandlerContext ctx = mock(ChannelHandlerContext.class); + private final Exception exception = new IllegalStateException("failed"); + + @Test + @DisplayName("Should swallow every exception and not handle exceptionCaught by default") + void adapterShouldSwallowExceptions() throws Exception { + ExceptionListenerAdapter listener = new ExceptionListenerAdapter() { + @Override + public void onAuthException(Throwable e, SocketIOClient client) { + } + }; + + assertThatCode(() -> { + listener.onEventException(exception, Collections.singletonList("data"), client); + listener.onDisconnectException(exception, client); + listener.onConnectException(exception, client); + listener.onPingException(exception, client); + listener.onPongException(exception, client); + }).doesNotThrowAnyException(); + + assertThat(listener.exceptionCaught(ctx, exception)).isFalse(); + } + + @Test + @DisplayName("Should log every exception and handle exceptionCaught by default") + void defaultListenerShouldHandleExceptionCaught() throws Exception { + DefaultExceptionListener listener = new DefaultExceptionListener(); + + assertThatCode(() -> { + listener.onEventException(exception, Collections.singletonList("data"), client); + listener.onDisconnectException(exception, client); + listener.onConnectException(exception, client); + listener.onPingException(exception, client); + listener.onPongException(exception, client); + listener.onAuthException(exception, client); + }).doesNotThrowAnyException(); + + assertThat(listener.exceptionCaught(ctx, exception)).isTrue(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/MicrometerSocketIOMetricsTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/MicrometerSocketIOMetricsTest.java new file mode 100644 index 00000000..c7467ee7 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/MicrometerSocketIOMetricsTest.java @@ -0,0 +1,237 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.metrics; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +@DisplayName("MicrometerSocketIOMetrics Tests") +class MicrometerSocketIOMetricsTest { + + private static final String NS = "chat"; + + private MeterRegistry registry; + private MicrometerSocketIOMetrics metrics; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + metrics = new MicrometerSocketIOMetrics(registry); + } + + @AfterEach + void tearDown() { + metrics.close(); + registry.close(); + } + + private Counter counter(String name, String namespace) { + return registry.get(name).tag("namespace", namespace).counter(); + } + + private Gauge gauge(String name, String namespace) { + return registry.get(name).tag("namespace", namespace).gauge(); + } + + private Timer timer(String name, String namespace) { + return registry.get(name).tag("namespace", namespace).timer(); + } + + @Test + @DisplayName("Should reject a null registry") + void shouldRejectNullRegistry() { + assertThatThrownBy(() -> new MicrometerSocketIOMetrics(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("registry can not be null"); + } + + @Test + @DisplayName("Should expose the registry it was built with") + void shouldExposeRegistry() { + assertThat(metrics.getRegistry()).isSameAs(registry); + assertThat(metrics.registry()).isSameAs(registry); + assertThat(MicrometerMetricsFactory.using(registry, true).getRegistry()).isSameAs(registry); + } + + @Test + @DisplayName("Should count received, handled and failed events") + void shouldCountEvents() { + metrics.eventReceived(NS); + metrics.eventReceived(NS); + metrics.eventHandled(NS, TimeUnit.MILLISECONDS.toNanos(20)); + metrics.eventFailed(NS); + metrics.unknownEventReceived(NS); + + assertThat(counter("socketio.event.received", NS).count()).isEqualTo(2); + assertThat(counter("socketio.event.handled", NS).count()).isEqualTo(1); + assertThat(counter("socketio.event.failed", NS).count()).isEqualTo(1); + assertThat(counter("socketio.event.unknown.total", NS).count()).isEqualTo(1); + assertThat(timer("socketio.event.processing.time", NS).totalTime(TimeUnit.MILLISECONDS)) + .isEqualTo(20.0); + } + + @Test + @DisplayName("Should not record event processing time for non positive durations") + void shouldNotRecordNonPositiveEventDuration() { + metrics.eventHandled(NS, 0); + + assertThat(counter("socketio.event.handled", NS).count()).isEqualTo(1); + assertThat(timer("socketio.event.processing.time", NS).count()).isZero(); + } + + @Test + @DisplayName("Should count sent events by recipient amount and ignore empty broadcasts") + void shouldCountSentEvents() { + metrics.eventSent(NS, 3); + metrics.eventSent(NS, 0); + + assertThat(counter("socketio.event.sent", NS).count()).isEqualTo(3); + } + + @Test + @DisplayName("Should record ack counters and latency") + void shouldRecordAckMetrics() { + metrics.ackSent(NS, TimeUnit.MILLISECONDS.toNanos(5)); + metrics.ackSent(NS, 0); + metrics.ackMissing(NS); + + assertThat(counter("socketio.ack.sent", NS).count()).isEqualTo(2); + assertThat(counter("socketio.ack.missing", NS).count()).isEqualTo(1); + assertThat(timer("socketio.ack.latency", NS).count()).isEqualTo(1); + } + + @Test + @DisplayName("Should track connected clients gauge") + void shouldTrackConnectedClients() { + metrics.connect(NS); + metrics.connect(NS); + metrics.disconnect(NS); + + assertThat(counter("socketio.connect.total", NS).count()).isEqualTo(2); + assertThat(counter("socketio.disconnect.total", NS).count()).isEqualTo(1); + assertThat(gauge("socketio.clients.connected", NS).value()).isEqualTo(1.0); + } + + @Test + @DisplayName("Should track room members gauge and never drop below zero") + void shouldTrackRoomMembers() { + metrics.roomJoin(NS); + metrics.roomLeave(NS); + metrics.roomLeave(NS); + + assertThat(counter("socketio.room.join.total", NS).count()).isEqualTo(1); + assertThat(counter("socketio.room.leave.total", NS).count()).isEqualTo(2); + assertThat(gauge("socketio.room.members", NS).value()).isZero(); + } + + @Test + @DisplayName("Should report the empty namespace as 'default'") + void shouldMapEmptyNamespaceToDefault() { + metrics.eventReceived(""); + metrics.eventHandled("", 1); + metrics.eventFailed(""); + metrics.eventSent("", 1); + metrics.unknownEventReceived(""); + metrics.ackSent("", 1); + metrics.ackMissing(""); + metrics.connect(""); + metrics.disconnect(""); + metrics.roomJoin(""); + metrics.roomLeave(""); + + assertThat(counter("socketio.event.received", "default").count()).isEqualTo(1); + assertThat(counter("socketio.connect.total", "default").count()).isEqualTo(1); + assertThat(counter("socketio.room.join.total", "default").count()).isEqualTo(1); + } + + @Test + @DisplayName("Should keep separate meters per namespace") + void shouldKeepSeparateMetersPerNamespace() { + metrics.eventReceived(NS); + metrics.eventReceived("news"); + + assertThat(counter("socketio.event.received", NS).count()).isEqualTo(1); + assertThat(counter("socketio.event.received", "news").count()).isEqualTo(1); + } + + @Test + @DisplayName("Should publish the distinct unknown event name estimate") + void shouldPublishDistinctUnknownEventNameEstimate() { + metrics.unknownEventNames(NS, null); + metrics.unknownEventNames(NS, "first"); + + // the estimate snapshot is published periodically, not on every record + await().atMost(30, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + metrics.unknownEventNames(NS, "second"); + assertThat(gauge("socketio.event.unknown.distinct.estimate", NS).value()) + .isGreaterThan(0.0); + }); + } + + @Test + @DisplayName("Should publish percentile histograms when enabled") + void shouldPublishHistogramWhenEnabled() { + MicrometerSocketIOMetrics histogramMetrics = MicrometerMetricsFactory.using(registry, true); + try { + histogramMetrics.eventHandled(NS, TimeUnit.MILLISECONDS.toNanos(3)); + + assertThat(timer("socketio.event.processing.time", NS).count()).isEqualTo(1); + assertThat(timer("socketio.event.processing.time", NS).takeSnapshot().percentileValues()) + .isEmpty(); + } finally { + histogramMetrics.close(); + } + } + + @Test + @DisplayName("Should ignore every call on the noop implementation") + void shouldIgnoreCallsOnNoopMetrics() { + SocketIOMetrics noop = SocketIOMetrics.noop(); + + noop.eventReceived(NS); + noop.eventHandled(NS, 1); + noop.eventFailed(NS); + noop.eventSent(NS, 1); + noop.unknownEventReceived(NS); + noop.unknownEventNames(NS, "name"); + noop.ackSent(NS, 1); + noop.ackMissing(NS); + noop.connect(NS); + noop.disconnect(NS); + noop.roomJoin(NS); + noop.roomLeave(NS); + + assertThat(SocketIOMetrics.noop()).isSameAs(noop); + assertThat(registry.getMeters()).isEmpty(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/NamespaceMetersTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/NamespaceMetersTest.java new file mode 100644 index 00000000..5588f482 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/metrics/NamespaceMetersTest.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.metrics; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("NamespaceMeters Tests") +class NamespaceMetersTest { + + private MeterRegistry registry; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + } + + @Test + @DisplayName("Should register every meter tagged with the namespace") + void shouldRegisterAllMeters() { + new NamespaceMeters(registry, "chat", false); + + assertThat(registry.getMeters()) + .allMatch(meter -> "chat".equals(meter.getId().getTag("namespace"))) + .extracting(meter -> meter.getId().getName()) + .contains("socketio.event.received", + "socketio.event.handled", + "socketio.event.failed", + "socketio.event.sent", + "socketio.event.unknown.total", + "socketio.event.unknown.distinct.estimate", + "socketio.ack.sent", + "socketio.ack.missing", + "socketio.connect.total", + "socketio.disconnect.total", + "socketio.clients.connected", + "socketio.room.join.total", + "socketio.room.leave.total", + "socketio.room.members", + "socketio.event.processing.time", + "socketio.ack.latency"); + } + + @Test + @DisplayName("Should allow the empty namespace but reject null arguments") + void shouldValidateConstructorArguments() { + assertThat(new NamespaceMeters(registry, "", false)).isNotNull(); + assertThatThrownBy(() -> new NamespaceMeters(null, "chat", false)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new NamespaceMeters(registry, null, false)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("Should expose the registered meters through accessors") + void shouldExposeMetersThroughAccessors() { + NamespaceMeters meters = new NamespaceMeters(registry, "chat", false); + + meters.getEventReceived().increment(); + meters.getEventHandled().increment(); + meters.getEventFailed().increment(); + meters.getEventSent().increment(2); + meters.getEventUnknown().increment(); + meters.getAckSent().increment(); + meters.getAckMissing().increment(); + meters.getConnect().increment(); + meters.getDisconnect().increment(); + meters.getRoomJoin().increment(); + meters.getRoomLeave().increment(); + meters.getConnected().set(7); + meters.getRoomMembers().set(3); + + assertThat(meters.getEventReceived().count()).isEqualTo(1); + assertThat(meters.getEventSent().count()).isEqualTo(2); + assertThat(meters.getConnected().get()).isEqualTo(7); + assertThat(meters.getRoomMembers().get()).isEqualTo(3); + assertThat(meters.getEventProcessing().count()).isZero(); + assertThat(meters.getAckLatency().count()).isZero(); + } + + @Test + @DisplayName("Should keep the published distinct estimate empty until the publish interval elapses") + void shouldNotPublishDistinctEstimateImmediately() { + NamespaceMeters meters = new NamespaceMeters(registry, "chat", true); + + meters.recordUnknownEvent(1L); + meters.recordUnknownEvent(2L); + + assertThat(registry.get("socketio.event.unknown.distinct.estimate").gauge().value()).isZero(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/PublishConfigTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/PublishConfigTest.java new file mode 100644 index 00000000..f0dd6db7 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/PublishConfigTest.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.event; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("PublishConfig Tests") +class PublishConfigTest { + + @ParameterizedTest + @EnumSource(EventType.class) + @DisplayName("Should apply the default mode to every event type") + void shouldApplyDefaultMode(EventType type) { + assertThat(PublishConfig.allReliable().get(type)).isEqualTo(PublishMode.RELIABLE); + assertThat(PublishConfig.allUnreliable().get(type)).isEqualTo(PublishMode.UNRELIABLE); + } + + @Test + @DisplayName("Should expose the default mode") + void shouldExposeDefaultMode() { + assertThat(PublishConfig.allReliable().getDefaultMode()).isEqualTo(PublishMode.RELIABLE); + assertThat(PublishConfig.allUnreliable().getDefaultMode()).isEqualTo(PublishMode.UNRELIABLE); + } + + @Test + @DisplayName("Should prefer overrides over the default mode") + void shouldPreferOverrides() { + Map overrides = new EnumMap<>(EventType.class); + overrides.put(EventType.DISPATCH, PublishMode.UNRELIABLE); + + PublishConfig reliable = PublishConfig.allReliable(overrides); + assertThat(reliable.get(EventType.DISPATCH)).isEqualTo(PublishMode.UNRELIABLE); + assertThat(reliable.get(EventType.CONNECT)).isEqualTo(PublishMode.RELIABLE); + + PublishConfig unreliable = PublishConfig.allUnreliable( + Collections.singletonMap(EventType.CONNECT, PublishMode.RELIABLE)); + assertThat(unreliable.get(EventType.CONNECT)).isEqualTo(PublishMode.RELIABLE); + assertThat(unreliable.get(EventType.DISPATCH)).isEqualTo(PublishMode.UNRELIABLE); + } + + @Test + @DisplayName("Should copy the overrides given at construction time") + void shouldCopyOverrides() { + Map overrides = new HashMap<>(); + overrides.put(EventType.JOIN, PublishMode.UNRELIABLE); + PublishConfig config = PublishConfig.allReliable(overrides); + + overrides.put(EventType.LEAVE, PublishMode.UNRELIABLE); + + assertThat(config.get(EventType.JOIN)).isEqualTo(PublishMode.UNRELIABLE); + assertThat(config.get(EventType.LEAVE)).isEqualTo(PublishMode.RELIABLE); + } + + @Test + @DisplayName("Should reject null arguments") + void shouldRejectNullArguments() { + assertThatThrownBy(() -> new PublishConfig(null, Collections.emptyMap())) + .isInstanceOf(NullPointerException.class) + .hasMessage("defaultMode must not be null"); + assertThatThrownBy(() -> new PublishConfig(PublishMode.RELIABLE, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("overrides must not be null"); + assertThatThrownBy(() -> PublishConfig.allReliable().get(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("type must not be null"); + } + + @Test + @DisplayName("Should print event types in lower case") + void shouldPrintEventTypesInLowerCase() { + assertThat(EventType.BULK_JOIN).hasToString("bulk_join"); + assertThat(EventType.valueOf("DISPATCH")).isEqualTo(EventType.DISPATCH); + assertThat(EventStoreType.values()).containsExactly(EventStoreType.LOCAL, + EventStoreType.PUBSUB, EventStoreType.STREAM, EventStoreType.BROKER); + } +}