diff --git a/.buildkite/lora_pipeline.yml b/.buildkite/lora_pipeline.yml index 2c3512cb1..d23101923 100644 --- a/.buildkite/lora_pipeline.yml +++ b/.buildkite/lora_pipeline.yml @@ -17,5 +17,5 @@ steps: - trigger: "090-server-ml-snapshot-build" - wait - + - trigger: "100-server-3rd-party-extensions-snapshot" - trigger: "200-build-windows-installer" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c37f33840..c1a7d23bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ +# Build outputs target/ +*.class +*.jar +!**/src/**/lib/*.jar + +# Maven pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup @@ -9,4 +15,26 @@ buildNumber.properties .mvn/timing.properties # https://github.com/takari/maven-wrapper#usage-without-binary-jar .mvn/wrapper/maven-wrapper.jar -.idea +.m2_local + +# IDE / editor +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Local / machine-specific +.claude/settings.local.json + +# Logs and temp +*.log +*.log.gz +*.tmp +*.bak +*~ + +# OS +.DS_Store +Thumbs.db +.gitnexus diff --git a/README.md b/README.md index cb453630e..dcfcb7843 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Wire protocol standardization has promised interoperability and flexibility in I - **Security Domains:** Configurable security domains allow for tailored authentication and authorization on a per-adapter/protocol basis. - **Flexible Configuration:** Supports configuration through both Consul and file-based setups, catering to various deployment environments. +Want to go deeper? [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Maps-Messaging/mapsmessaging_server) + ## Getting Started: "Hello World" Example This example demonstrates a simple publish/subscribe scenario using the MQTT protocol. @@ -92,4 +94,4 @@ For full license terms, see the [LICENSE](LICENSE) file in the repository. | Web Admin Client | [![Build status](https://badge.buildkite.com/7cc9381cb4e32048a4978e91f483113a47217238b29461534e.svg)](https://buildkite.com/mapsmessaging/060-maps-web-client)| [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=web-admin-client&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=web-admin-client)| -[![Mutable.ai Auto Wiki](https://img.shields.io/badge/Auto_Wiki-Mutable.ai-blue)](https://wiki.mutable.ai/Maps-Messaging/mapsmessaging_server) \ No newline at end of file +[![Mutable.ai Auto Wiki](https://img.shields.io/badge/Auto_Wiki-Mutable.ai-blue)](https://wiki.mutable.ai/Maps-Messaging/mapsmessaging_server) diff --git a/pom.xml b/pom.xml index 88964adb2..e11f547e7 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ io.mapsmessaging maps 4.0.0 - 4.3.0 + 4.3.1-SNAPSHOT jar Maps Messaging Server @@ -87,9 +87,9 @@ ${env.NVD_API_KEY} UTF-8 - 1.5.21 + 1.5.22 2.8.0 - 4.0.0 + 4.0.1 3.17.0 **/*Suite.class @@ -133,21 +133,25 @@ 21 - 2.1.1 - 2.1.0 - ml-2.1.0 - 1.2.1 + 2.1.2-SNAPSHOT + 1.0.1-SNAPSHOT + 2.1.1-SNAPSHOT + ml-2.1.1-SNAPSHOT + 1.2.2-SNAPSHOT - 2.2.1 - 3.0.0 - 1.1.3 + 1.0.1-SNAPSHOT + 2.2.2-SNAPSHOT + 3.0.1-SNAPSHOT + 1.1.5-SNAPSHOT - 2.5.1 - 3.1.0 - 2.0.1 + 2.5.2-SNAPSHOT + 3.1.1-SNAPSHOT + 3.0.2-SNAPSHOT + false + ${project.build.directory}/license @@ -317,6 +321,28 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.6.2 + + + fetch-community-license + prepare-package + + java + + + ${license.fetch.skip} + io.mapsmessaging.license.tools.LicenseFetcher + compile + + ${license.fetch.dir} + + + + + org.apache.maven.plugins @@ -394,6 +420,7 @@ **/*Test.java + **/*Tests.java **/*IT.java @@ -425,7 +452,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.1 + 3.4.0 attach-sources @@ -546,6 +573,24 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + config-lint + verify + + java + + + io.mapsmessaging.tools.config.lint.ConfigLintMain + compile + + + + @@ -578,7 +623,7 @@ software.amazon.awssdk bom - 2.38.6 + 2.41.13 pom import @@ -594,11 +639,41 @@ + + io.mapsmessaging + canbus-core + 1.0.0-SNAPSHOT + + + + io.mapsmessaging + canbus-J1939 + 1.0.0-SNAPSHOT + + + + io.mapsmessaging + canbus-nmea2000 + 1.0.0-SNAPSHOT + + + io.mapsmessaging simple_logging ${maps.logging.version} + + io.mapsmessaging + mavlink + ${maps.mavlink.version} + + + io.mapsmessaging + JsonQuery + ${maps.jsonquery.version} + + io.mapsmessaging jms_selector_parser @@ -760,13 +835,13 @@ io.swagger.core.v3 swagger-jaxrs2-jakarta - 2.2.40 + 2.2.42 io.swagger.core.v3 swagger-jaxrs2-servlet-initializer-v2-jakarta - 2.2.40 + 2.2.42 @@ -882,7 +957,12 @@ cognitoidentityprovider 2.38.6 - + + + com.networknt + json-schema-validator + 2.0.0 + @@ -986,7 +1066,7 @@ com.hivemq hivemq-mqtt-client - 1.3.10 + 1.3.12 test @@ -1117,12 +1197,48 @@ 3.14.0 test + + + org.mockito + mockito-junit-jupiter + 5.21.0 + test + org.eclipse.californium scandium 3.14.0 test + + + + io.rest-assured + rest-assured + 5.5.7 + test + + + org.java-websocket + Java-WebSocket + 1.5.3 + + + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + + + + + com.atlassian.oai + swagger-request-validator-restassured + 2.46.0 + test + + diff --git a/src/main/assemble/scripts.xml b/src/main/assemble/scripts.xml index 042d9a200..8e378429c 100644 --- a/src/main/assemble/scripts.xml +++ b/src/main/assemble/scripts.xml @@ -1,7 +1,7 @@ + diff --git a/src/main/scripts/docker_run.sh b/src/main/scripts/docker_run.sh index 1f3153d39..f7cc5ef4c 100644 --- a/src/main/scripts/docker_run.sh +++ b/src/main/scripts/docker_run.sh @@ -1,7 +1,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/download-extensions.ps1 b/src/main/scripts/download-extensions.ps1 new file mode 100644 index 000000000..4d27a564c --- /dev/null +++ b/src/main/scripts/download-extensions.ps1 @@ -0,0 +1,73 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoBase = "https://repository.mapsmessaging.io/repository/maps_snapshots" + +function Get-LatestSnapshotJarValue { + param( + [Parameter(Mandatory=$true)][string]$MetadataXml, + [string]$Classifier = $null + ) + + [xml]$xml = $MetadataXml + $snapshotVersions = $xml.metadata.versioning.snapshotVersions.snapshotVersion + if (-not $snapshotVersions) { + throw "No snapshotVersions found in maven-metadata.xml" + } + + if ([string]::IsNullOrWhiteSpace($Classifier)) { + $match = $snapshotVersions | Where-Object { + $_.extension -eq "jar" -and (-not $_.classifier -or $_.classifier -eq "") + } | Select-Object -First 1 + } else { + $match = $snapshotVersions | Where-Object { + $_.extension -eq "jar" -and $_.classifier -eq $Classifier + } | Select-Object -First 1 + } + + if (-not $match) { + throw "No matching jar snapshotVersion found (classifier='$Classifier')" + } + + return $match.value +} + +function Download-LatestSnapshotJarAndRename { + param( + [Parameter(Mandatory=$true)][string]$GroupId, + [Parameter(Mandatory=$true)][string]$ArtifactId, + [Parameter(Mandatory=$true)][string]$Version, # e.g. 1.0.0-SNAPSHOT + [string]$Classifier = $null + ) + + $groupPath = $GroupId -replace "\.", "/" + $artifactDir = "$RepoBase/$groupPath/$ArtifactId/$Version" + $metadataUrl = "$artifactDir/maven-metadata.xml" + + Write-Host "==> $GroupId`:$ArtifactId`:$Version$([string]::IsNullOrWhiteSpace($Classifier) ? "" : ":$Classifier")" + Write-Host " metadata: $metadataUrl" + + $metadataXml = (Invoke-WebRequest -Uri $metadataUrl -UseBasicParsing).Content + $jarValue = Get-LatestSnapshotJarValue -MetadataXml $metadataXml -Classifier $Classifier + + $timestampedFile = "$ArtifactId-$jarValue.jar" + $jarUrl = "$artifactDir/$timestampedFile" + + $finalFile = "$ArtifactId-$Version.jar" # <-- the name you want + + Write-Host " download: $jarUrl" + Invoke-WebRequest -Uri $jarUrl -OutFile $timestampedFile -UseBasicParsing + + if (Test-Path $finalFile) { Remove-Item -Force $finalFile } + Rename-Item -Path $timestampedFile -NewName $finalFile + + Write-Host " saved as: $finalFile" + Write-Host "" +} + + +# ---- Examples (fill in the exact artifactIds/versions you use) ---- +Download-LatestSnapshotJarAndRename -GroupId "io.mapsmessaging" -ArtifactId "aws-sns-extension" -Version "1.0.0-SNAPSHOT" +Download-LatestSnapshotJarAndRename -GroupId "io.mapsmessaging" -ArtifactId "ibm-mq-extension" -Version "1.0.0-SNAPSHOT" +Download-LatestSnapshotJarAndRename -GroupId "io.mapsmessaging" -ArtifactId "pulsar-extension" -Version "1.0.0-SNAPSHOT" +Download-LatestSnapshotJarAndRename -GroupId "io.mapsmessaging" -ArtifactId "v2x-step-extension" -Version "1.0.0-SNAPSHOT" diff --git a/src/main/scripts/download-extensions.sh b/src/main/scripts/download-extensions.sh new file mode 100644 index 000000000..346fec4fb --- /dev/null +++ b/src/main/scripts/download-extensions.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# +# Copyright [ 2020 - 2024 ] Matthew Buckton +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. +# +# Licensed under the Apache License, Version 2.0 with the Commons Clause +# (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 +# https://commonsclause.com/ +# +# 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. +# +#!/usr/bin/env bash +set -euo pipefail + +REPO_BASE="https://repository.mapsmessaging.io/repository/maps_snapshots" + +download_latest_snapshot_jar_and_rename() { + local group_id="$1" + local artifact_id="$2" + local version="$3" # e.g. 1.0.0-SNAPSHOT + local classifier="${4:-}" # optional + + local group_path + group_path="$(echo "${group_id}" | tr '.' '/')" + + local artifact_dir="${REPO_BASE}/${group_path}/${artifact_id}/${version}" + local metadata_url="${artifact_dir}/maven-metadata.xml" + + echo "==> ${group_id}:${artifact_id}:${version}${classifier:+:${classifier}}" + echo " metadata: ${metadata_url}" + + local metadata + metadata="$(curl -fsSL "${metadata_url}")" + + local jar_value="" + if [[ -n "${classifier}" ]]; then + jar_value="$(echo "${metadata}" | awk -v cls="${classifier}" ' + BEGIN { RS=""; FS="\n" } + $0 ~ // && $0 ~ "jar" && $0 ~ ""cls"" { + if (match($0, /[^<]+<\/value>/)) { + v=substr($0, RSTART+7, RLENGTH-15); print v; exit + } + }')" + else + jar_value="$(echo "${metadata}" | awk ' + BEGIN { RS=""; FS="\n" } + $0 ~ // && $0 ~ "jar" && $0 !~ "" { + if (match($0, /[^<]+<\/value>/)) { + v=substr($0, RSTART+7, RLENGTH-15); print v; exit + } + }')" + fi + + if [[ -z "${jar_value}" ]]; then + echo " ERROR: Could not find a jar snapshotVersion in metadata." >&2 + exit 1 + fi + + local timestamped_file="${artifact_id}-${jar_value}.jar" + local jar_url="${artifact_dir}/${timestamped_file}" + + local final_file="${artifact_id}-${version}.jar" # <-- the name you want + + echo " download: ${jar_url}" + curl -fL --retry 3 --retry-delay 1 -o "${timestamped_file}" "${jar_url}" + + mv -f "${timestamped_file}" "${final_file}" + echo " saved as: ${final_file}" + echo +} + +# ---- Examples (fill in the exact artifactIds/versions you use) ---- +download_latest_snapshot_jar_and_rename "io.mapsmessaging" "aws-sns-extension" "1.0.0-SNAPSHOT" +download_latest_snapshot_jar_and_rename "io.mapsmessaging" "ibm-mq-extension" "1.0.0-SNAPSHOT" +download_latest_snapshot_jar_and_rename "io.mapsmessaging" "pulsar-extension" "1.0.0-SNAPSHOT" +download_latest_snapshot_jar_and_rename "io.mapsmessaging" "v2x-step-extension" "1.0.0-SNAPSHOT" diff --git a/src/main/scripts/download-smile.sh b/src/main/scripts/download-smile.sh index 97589db57..703459d23 100644 --- a/src/main/scripts/download-smile.sh +++ b/src/main/scripts/download-smile.sh @@ -1,13 +1,9 @@ #!/bin/bash -# This script downloads Smile ML libraries (GPL-3.0 licensed) from Maven Central. -# You are responsible for complying with the Smile license (https://github.com/haifengl/smile). -# MapsMessaging does not distribute Smile or bundle it directly. - # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. @@ -23,6 +19,12 @@ # limitations under the License. # +# This script downloads Smile ML libraries (GPL-3.0 licensed) from Maven Central. +# You are responsible for complying with the Smile license (https://github.com/haifengl/smile). +# MapsMessaging does not distribute Smile or bundle it directly. + + + current_dir=$(pwd) if [[ "$current_dir" == */bin ]]; then parent_dir=$(dirname "$current_dir") diff --git a/src/main/scripts/fail2ban/fail2ban-config.sh b/src/main/scripts/fail2ban/fail2ban-config.sh new file mode 100644 index 000000000..c4bb17045 --- /dev/null +++ b/src/main/scripts/fail2ban/fail2ban-config.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# +# Copyright [ 2020 - 2024 ] Matthew Buckton +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. +# +# Licensed under the Apache License, Version 2.0 with the Commons Clause +# (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 +# https://commonsclause.com/ +# +# 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. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +FILTER_SRC="${SCRIPT_DIR}/maps-auth.conf" +JAIL_SRC="${SCRIPT_DIR}/maps-auth.local" + +FILTER_DST="/etc/fail2ban/filter.d/maps-auth.conf" +JAIL_DST="/etc/fail2ban/jail.d/maps-auth.local" + +if [[ "${EUID}" -ne 0 ]]; then + echo "This script must be run as root (use sudo)." >&2 + exit 1 +fi + +if ! command -v fail2ban-client >/dev/null 2>&1; then + echo "fail2ban-client not found. Fail2ban does not appear to be installed." >&2 + exit 1 +fi + +if [[ ! -f "${FILTER_SRC}" ]]; then + echo "Missing filter file: ${FILTER_SRC}" >&2 + exit 1 +fi + +if [[ ! -f "${JAIL_SRC}" ]]; then + echo "Missing jail file: ${JAIL_SRC}" >&2 + exit 1 +fi + +echo "Installing Fail2ban filter: ${FILTER_SRC} -> ${FILTER_DST}" +install -m 0644 -D "${FILTER_SRC}" "${FILTER_DST}" + +echo "Installing Fail2ban jail override: ${JAIL_SRC} -> ${JAIL_DST}" +install -m 0644 -D "${JAIL_SRC}" "${JAIL_DST}" + +echo "Validating Fail2ban configuration..." +fail2ban-client -t + +echo "Reloading Fail2ban..." +if systemctl is-active --quiet fail2ban; then + systemctl restart fail2ban +else + fail2ban-client reload || true +fi + +echo "Checking jail status for 'maps-auth'..." +fail2ban-client status maps-auth + + +echo "Done." diff --git a/src/main/scripts/fail2ban/maps-auth.conf b/src/main/scripts/fail2ban/maps-auth.conf new file mode 100644 index 000000000..551e9f8e1 --- /dev/null +++ b/src/main/scripts/fail2ban/maps-auth.conf @@ -0,0 +1,19 @@ +[Definition] +# Match failures and lockouts emitted by MAPS auth monitor logging. +# Assumes the log line contains one of these tokens and the client IP address. +# +# Example lines this will match (examples only): +# ... AUTH_FAILURE user=bob attempts=3 ip=203.0.113.9 +# ... AUTH_LOCKOUT_STARTED user=bob attempts=5 lockSeconds=60 ip=203.0.113.9 +# +# If your log format is different, adjust the tail so the IP is captured as . + +failregex = + ^.*\bAUTH_FAILURE\b.*\bip=\b.*$ + ^.*\bAUTH_LOCKOUT_STARTED\b.*\bip=\b.*$ + ^.*EndPoint closed during protocol negotiation.*\bip=\b.*$ + ^.*Failed to detect protocol on End Point .*?,\s*\bip=\b.*$ + ^.*Packet integrity verification failed:.*\breason=SIGNATURE_MISMATCH\b.*\bip=\b.*$ + +ignoreregex = ^.*ip=127\..*$ + ^.*ip=::1.*$ diff --git a/src/main/scripts/fail2ban/maps-auth.local b/src/main/scripts/fail2ban/maps-auth.local new file mode 100644 index 000000000..52e74c8c3 --- /dev/null +++ b/src/main/scripts/fail2ban/maps-auth.local @@ -0,0 +1,14 @@ +[maps-auth] +enabled = true +filter = maps-auth + +# Point this at your MAPS log file +logpath = /opt/maps_data/log/messaging.log + +# How aggressive you want it: +findtime = 300 +maxretry = 5 +bantime = 3600 + +# If your host is behind a proxy and logs proxy IPs, fix logging first. +# action defaults to banning via firewall (nftables/iptables) depending on distro. diff --git a/src/main/scripts/generate.sh b/src/main/scripts/generate.sh index a6ccc42f2..36037b65d 100755 --- a/src/main/scripts/generate.sh +++ b/src/main/scripts/generate.sh @@ -2,7 +2,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/maps b/src/main/scripts/maps index de76e4628..72634bdac 100644 --- a/src/main/scripts/maps +++ b/src/main/scripts/maps @@ -3,7 +3,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/mapsTop.sh b/src/main/scripts/mapsTop.sh index 7e1e392d6..1cd50ddc6 100644 --- a/src/main/scripts/mapsTop.sh +++ b/src/main/scripts/mapsTop.sh @@ -1,7 +1,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/pack.sh b/src/main/scripts/pack.sh index 10dc5bcb1..9dc983cdf 100755 --- a/src/main/scripts/pack.sh +++ b/src/main/scripts/pack.sh @@ -1,7 +1,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/start.sh b/src/main/scripts/start.sh index 57fc9efcd..e08c6d34d 100644 --- a/src/main/scripts/start.sh +++ b/src/main/scripts/start.sh @@ -2,7 +2,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/main/scripts/startDocker.sh b/src/main/scripts/startDocker.sh index 4c8c57ee7..a50a8a366 100644 --- a/src/main/scripts/startDocker.sh +++ b/src/main/scripts/startDocker.sh @@ -1,7 +1,7 @@ # # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. @@ -38,11 +38,11 @@ export CONSUL_URL echo $CONSUL_URL if [ -z ${MAPS_HOME+x} ]; - then export MAPS_HOME=/maps-$VERSION; + then export MAPS_HOME=/opt/maps; fi if [ -z ${MAPS_DATA+x} ]; - then export MAPS_DATA=/data + then export MAPS_DATA=/opt/maps_data fi echo "Maps Home is set to '$MAPS_HOME'" diff --git a/src/test/java/io/mapsmessaging/BaseTest.java b/src/test/java/io/mapsmessaging/BaseTest.java index 9952e0c01..3652b69ce 100644 --- a/src/test/java/io/mapsmessaging/BaseTest.java +++ b/src/test/java/io/mapsmessaging/BaseTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/StoreFiller.java b/src/test/java/io/mapsmessaging/StoreFiller.java index 58dacfa9f..9244292ef 100644 --- a/src/test/java/io/mapsmessaging/StoreFiller.java +++ b/src/test/java/io/mapsmessaging/StoreFiller.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/aggregator/AggregatorSpec.java b/src/test/java/io/mapsmessaging/aggregator/AggregatorSpec.java new file mode 100644 index 000000000..b31f16013 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/AggregatorSpec.java @@ -0,0 +1,56 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import lombok.Data; + +@Data +public class AggregatorSpec { + + private final String outputTopic; + private final String in1; + private final String in2; + private final String in3; + + private AggregatorSpec(String outputTopic, String in1, String in2, String in3) { + this.outputTopic = outputTopic; + this.in1 = in1; + this.in2 = in2; + this.in3 = in3; + } + + public static AggregatorSpec aggregator1() { + return new AggregatorSpec( + "/aggregator1/out1", + "/aggregator1/in1", + "/aggregator1/in2", + "/aggregator1/in3" + ); + } + + public static AggregatorSpec aggregator2() { + return new AggregatorSpec( + "/aggregator2/out1", + "/aggregator2/in1", + "/aggregator2/in2", + "/aggregator2/in3" + ); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/aggregator/BaseAggreagtorTest.java b/src/test/java/io/mapsmessaging/aggregator/BaseAggreagtorTest.java new file mode 100644 index 000000000..b31e079d0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/BaseAggreagtorTest.java @@ -0,0 +1,77 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import io.mapsmessaging.api.*; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.logging.ServerLogMessages; +import io.mapsmessaging.test.BaseTestConfig; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class BaseAggreagtorTest extends BaseTestConfig { + + + protected Session createSession(String sessionId, MessageListener listener) throws LoginException, IOException { + return createSession(sessionId, 60, 60, false, listener, true); + } + + protected void closeSession(Session session) throws IOException { + super.close(session); + } + + protected SubscribedEventManager addSubscription(String topicName, Session session) throws IOException { + SubscriptionContextBuilder scb = new SubscriptionContextBuilder(topicName, ClientAcknowledgement.AUTO); + SubscriptionContext context = scb.setReceiveMaximum(100).setQos(QualityOfService.AT_MOST_ONCE).build(); + return session.addSubscription(context); + } + + protected void closeSubscription(SubscribedEventManager subscription, Session session) { + session.removeSubscription(subscription.getContext().getKey()); + } + + protected void publish(String topicName, byte[] payload, Session session) throws ExecutionException, InterruptedException, TimeoutException { + MessageBuilder messageBuilder = new MessageBuilder(); + messageBuilder.setOpaqueData(payload); + publish(topicName, messageBuilder.build(), session); + } + + protected void publish(String topicName, Message message, Session session) throws ExecutionException, InterruptedException, TimeoutException { + session.findDestination(topicName, DestinationType.TOPIC).thenApply(destination -> { + try { + if (destination != null) { + destination.storeMessage(message); + } + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + return destination; + }).get(1, TimeUnit.SECONDS); + } + +} diff --git a/src/test/java/io/mapsmessaging/aggregator/Envelope.java b/src/test/java/io/mapsmessaging/aggregator/Envelope.java new file mode 100644 index 000000000..8ba1d2026 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/Envelope.java @@ -0,0 +1,54 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import lombok.Getter; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Getter +public class Envelope { + + private final Map inputs; + + public Envelope(Map inputs) { + this.inputs = inputs; + } + + Object getPayloadField(String inputTopic, String fieldName) { + Object entryObj = inputs.get(inputTopic); + assertNotNull(entryObj, "Missing envelope entry for topic " + inputTopic); + + @SuppressWarnings("unchecked") + Map entry = (Map) entryObj; + + Object payloadObj = entry.get("payload"); + assertNotNull(payloadObj, "Expected 'payload' object for topic " + inputTopic); + + @SuppressWarnings("unchecked") + Map payload = (Map) payloadObj; + + Object val = payload.get(fieldName); + assertNotNull(val, "Missing payload field '" + fieldName + "' for topic " + inputTopic); + return val; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorOutboundTransformationSystemTest.java b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorOutboundTransformationSystemTest.java new file mode 100644 index 000000000..477dedee0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorOutboundTransformationSystemTest.java @@ -0,0 +1,245 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.MessageEvent; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscribedEventManager; +import io.mapsmessaging.api.message.Message; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Outbound transformation pipeline tests for aggregator-3. + * + * Intent: + * - Publish already-clean JSON inputs on /aggregator3/in1, /in2, /in3. + * - Aggregator builds its normal envelope internally, then outbound transformers reshape it. + * - Expected final output is a flat JSON object containing: + * { "runId": "...", "temp": 20, "humidity": 60, "pressure": 990 } + * + * This test is expected to FAIL until aggregator-3 outbound transformers are configured. + */ +class StaticAggregatorOutboundTransformationSystemTest extends BaseAggreagtorTest { + + private static final long COMPLETE_PUBLISH_DEADLINE_MS = 500; + private static final String JSON_CONTENT_TYPE = "application/json"; + + private static final String OUT_TOPIC = "/aggregator3/out1"; + private static final String IN1 = "/aggregator3/in1"; + private static final String IN2 = "/aggregator3/in2"; + private static final String IN3 = "/aggregator3/in3"; + + private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + private final Type mapType = new TypeToken>() {}.getType(); + + @Test + void aggregator3_completePublishes_andOutboundTransformationFlattensEnvelope() throws Exception { + + String runId = newRunId(); + BlockingQueue outQueue = new LinkedBlockingQueue<>(); + + MessageListener listener = messageEvent -> onOutputMessage(outQueue, runId, messageEvent); + + Session session = createSession("agg3-outbound-" + runId, listener); + SubscribedEventManager outSub = addSubscription(OUT_TOPIC, session); + + try { + drain(outQueue, 200); + Message temp = cleanTemperature(runId, 20); + Message humidity = cleanHumidity(runId, 60); + Message pressure = cleanPressure(runId, 990); + + publish(IN3, pressure, session); + publish(IN2, humidity, session); + publish(IN1, temp, session); + + Message out = pollOrFail(outQueue, COMPLETE_PUBLISH_DEADLINE_MS, + "Expected aggregation output within " + COMPLETE_PUBLISH_DEADLINE_MS + "ms of third publish"); + + assertNotNull(out.getOpaqueData(), "Output message had null payload"); + assertEquals(JSON_CONTENT_TYPE, out.getContentType(), "Output contentType must be application/json"); + + String outJson = toUtf8(out.getOpaqueData()); + Map root = gson.fromJson(outJson, mapType); + + // Critical outbound assertion: envelope must be flattened away. + assertFalse(root.containsKey("inputs"), "Outbound output still contains 'inputs' (not flattened). Output=" + pretty(outJson)); + + assertEquals(runId, stringField(root, "runId", outJson), "Missing/incorrect runId. Output=" + pretty(outJson)); + assertEquals(20.0, numberField(root, "temp", outJson), "Missing/incorrect temp. Output=" + pretty(outJson)); + assertEquals(60.0, numberField(root, "humidity", outJson), "Missing/incorrect humidity. Output=" + pretty(outJson)); + assertEquals(990.0, numberField(root, "pressure", outJson), "Missing/incorrect pressure. Output=" + pretty(outJson)); + } finally { + closeSubscription(outSub, session); + closeSession(session); + } + } + + private void onOutputMessage(BlockingQueue outQueue, String runId, MessageEvent messageEvent) { + try { + Message message = messageEvent.getMessage(); + System.out.println("Received message: " + messageEvent.getDestinationName()); + if (message == null) { + return; + } + + if (!OUT_TOPIC.equals(messageEvent.getDestinationName())) { + return; + } + + if (!JSON_CONTENT_TYPE.equals(message.getContentType())) { + return; + } + + byte[] bytes = message.getOpaqueData(); + if (bytes == null) { + return; + } + + String json = toUtf8(bytes); + + // Keep this filter: server is long-running and other tests exist. + // Recommendation: keep runId in the outbound flattened output. + if (!json.contains("\"runId\":\"" + runId + "\"")) { + return; + } + + outQueue.offer(message); + } finally { + Runnable completionTask = messageEvent.getCompletionTask(); + if (completionTask != null) { + completionTask.run(); + } + } + } + + private void drain(BlockingQueue queue, long durationMs) throws InterruptedException { + long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMs); + while (System.nanoTime() < end) { + Message ignored = queue.poll(10, TimeUnit.MILLISECONDS); + if (ignored == null) { + continue; + } + } + } + + private Message pollOrFail(BlockingQueue queue, long timeoutMs, String failureMessage) throws InterruptedException { + Message message = queue.poll(timeoutMs, TimeUnit.MILLISECONDS); + if (message == null) { + fail(failureMessage); + } + return message; + } + + private Message cleanTemperature(String runId, int temperatureC) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"temp\":" + temperatureC + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private Message cleanHumidity(String runId, int humidityPct) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"humidity\":" + humidityPct + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private Message cleanPressure(String runId, int pressureHpa) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"pressure\":" + pressureHpa + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private double numberField(Map root, String fieldName, String fullOutputJson) { + Object value = root.get(fieldName); + assertNotNull(value, "Missing field '" + fieldName + "'. Output=" + pretty(fullOutputJson)); + + if (value instanceof Number number) { + return number.doubleValue(); + } + + fail("Field '" + fieldName + "' was not a number (was " + value.getClass().getName() + "). Output=" + pretty(fullOutputJson)); + return 0; + } + + private String stringField(Map root, String fieldName, String fullOutputJson) { + Object value = root.get(fieldName); + assertNotNull(value, "Missing field '" + fieldName + "'. Output=" + pretty(fullOutputJson)); + + if (value instanceof String str) { + return str; + } + + fail("Field '" + fieldName + "' was not a string (was " + value.getClass().getName() + "). Output=" + pretty(fullOutputJson)); + return null; + } + + private String pretty(String json) { + try { + Object parsed = gson.fromJson(json, Object.class); + return new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(parsed); + } catch (Exception e) { + return json; + } + } + + private static String toUtf8(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private static String newRunId() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorSystemTest.java b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorSystemTest.java new file mode 100644 index 000000000..7a7dc964d --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorSystemTest.java @@ -0,0 +1,296 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import io.mapsmessaging.api.*; +import io.mapsmessaging.api.message.Message; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Assumptions: + * - The server is already running before tests execute and stays running. + * - Aggregation output is JSON: {"inputs": { "": { "payload": {...} } } } for JSON inputs. + * - Timeout starts at event0 (first contribution received for a new window). + * + * This test class uses runId embedded in JSON payload for filtering, so it remains stable even if + * correlation propagation / dataMap propagation changes. + */ +class StaticAggregatorSystemTest extends BaseAggreagtorTest { + + private static final long COMPLETE_PUBLISH_DEADLINE_MS = 500; + private static final long TIMEOUT_MS = 5000; + private static final long TIMEOUT_GRACE_MS = 500; + private static final long NO_EARLY_PUBLISH_ASSERT_MS = 4500; + + private static final String JSON_CONTENT_TYPE = "application/json"; + + private final Gson gson = new Gson(); + private final Type mapType = new TypeToken>() {}.getType(); + + @Test + void aggregator1_completePublishesImmediately() throws Exception { + AggregatorSpec spec = AggregatorSpec.aggregator1(); + runCompletePublishesImmediately(spec); + } + + @Test + void aggregator1_partialWaitsForTimeout() throws Exception { + AggregatorSpec spec = AggregatorSpec.aggregator1(); + runPartialWaitsForTimeout(spec); + } + + + @Test + void aggregator1_lastWinsWithinWindow() throws Exception { + AggregatorSpec spec = AggregatorSpec.aggregator1(); + runLastWinsWithinWindow(spec); + } + + + private void runCompletePublishesImmediately(AggregatorSpec spec) throws LoginException, IOException, ExecutionException, InterruptedException, TimeoutException { + + String runId = newRunId(); + BlockingQueue outQueue = new LinkedBlockingQueue<>(); + + MessageListener listener = messageEvent -> onMessage(outQueue, runId, messageEvent); + + Session session = createSession("agg-complete-" + runId, listener); + SubscribedEventManager sub = addSubscription(spec.getOutputTopic(), session); + + try { + drain(outQueue, 200); + + Message in1 = jsonMessage(runId, "topic", spec.getIn1(), "value", "v1"); + Message in2 = jsonMessage(runId, "topic", spec.getIn2(), "value", "v2"); + Message in3 = jsonMessage(runId, "topic", spec.getIn3(), "value", "v3"); + + publish(spec.getIn1(), in1, session); + publish(spec.getIn2(), in2, session); + + long thirdPublishTime = System.nanoTime(); + publish(spec.getIn3(), in3, session); + + Message out = pollOrFail(outQueue, COMPLETE_PUBLISH_DEADLINE_MS, "Expected output within " + COMPLETE_PUBLISH_DEADLINE_MS + "ms of third publish"); + long outDeltaMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - thirdPublishTime); + assertTrue(outDeltaMs <= COMPLETE_PUBLISH_DEADLINE_MS, "Output was not immediate; deltaMs=" + outDeltaMs); + + Envelope envelope = parseEnvelope(out); + assertEquals(3, envelope.getInputs().size(), "Expected exactly 3 inputs in envelope"); + assertTrue(envelope.getInputs().containsKey(spec.getIn1())); + assertTrue(envelope.getInputs().containsKey(spec.getIn2())); + assertTrue(envelope.getInputs().containsKey(spec.getIn3())); + + assertEquals(runId, envelope.getPayloadField(spec.getIn1(), "runId")); + assertEquals(runId, envelope.getPayloadField(spec.getIn2(), "runId")); + assertEquals(runId, envelope.getPayloadField(spec.getIn3(), "runId")); + + assertEquals("v1", envelope.getPayloadField(spec.getIn1(), "value")); + assertEquals("v2", envelope.getPayloadField(spec.getIn2(), "value")); + assertEquals("v3", envelope.getPayloadField(spec.getIn3(), "value")); + } finally { + closeSubscription(sub, session); + closeSession(session); + } + } + + private void runPartialWaitsForTimeout(AggregatorSpec spec) + throws LoginException, IOException, ExecutionException, InterruptedException, TimeoutException { + + String runId = newRunId(); + BlockingQueue outQueue = new LinkedBlockingQueue<>(); + + MessageListener listener = messageEvent -> onMessage(outQueue, runId, messageEvent); + + Session session = createSession("agg-timeout-" + runId, listener); + SubscribedEventManager sub = addSubscription(spec.getOutputTopic(), session); + + try { + drain(outQueue, 200); + + long t0 = System.nanoTime(); + Message in1 = jsonMessage(runId, "topic", spec.getIn1(), "value", "only1"); + publish(spec.getIn1(), in1, session); + + // Assert: no early publish well before timeout + Message early = outQueue.poll(NO_EARLY_PUBLISH_ASSERT_MS, TimeUnit.MILLISECONDS); + if (early != null) { + fail("Received output early (before timeout). Envelope=" + toUtf8(early.getOpaqueData())); + } + + long deadlineMs = TIMEOUT_MS + TIMEOUT_GRACE_MS; + long remainingMs = deadlineMs - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + if (remainingMs < 0) { + remainingMs = 0; + } + + Message out = pollOrFail(outQueue, remainingMs, "Expected output by timeout+grace (" + deadlineMs + "ms from event0)"); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0); + assertTrue(elapsedMs <= deadlineMs, "Output missed timeout+grace; elapsedMs=" + elapsedMs); + + Envelope envelope = parseEnvelope(out); + assertEquals(1, envelope.getInputs().size(), "Expected partial envelope with only 1 input present"); + assertTrue(envelope.getInputs().containsKey(spec.getIn1()), "Expected in1 present"); + assertFalse(envelope.getInputs().containsKey(spec.getIn2()), "Expected in2 missing"); + assertFalse(envelope.getInputs().containsKey(spec.getIn3()), "Expected in3 missing"); + + assertEquals(runId, envelope.getPayloadField(spec.getIn1(), "runId")); + assertEquals("only1", envelope.getPayloadField(spec.getIn1(), "value")); + } finally { + closeSubscription(sub, session); + closeSession(session); + } + } + + private void runLastWinsWithinWindow(AggregatorSpec spec) + throws LoginException, IOException, ExecutionException, InterruptedException, TimeoutException { + + String runId = newRunId(); + BlockingQueue outQueue = new LinkedBlockingQueue<>(); + + MessageListener listener = messageEvent -> onMessage(outQueue, runId, messageEvent); + + Session session = createSession("agg-last-" + runId, listener); + SubscribedEventManager sub = addSubscription(spec.getOutputTopic(), session); + + try { + drain(outQueue, 200); + + Message first = jsonMessage(runId, "topic", spec.getIn1(), "value", "old"); + Message second = jsonMessage(runId, "topic", spec.getIn1(), "value", "new"); + Message in2 = jsonMessage(runId, "topic", spec.getIn2(), "value", "v2"); + Message in3 = jsonMessage(runId, "topic", spec.getIn3(), "value", "v3"); + + publish(spec.getIn1(), first, session); + publish(spec.getIn1(), second, session); // LAST should win + publish(spec.getIn2(), in2, session); + + long thirdPublishTime = System.nanoTime(); + Thread.sleep(100); + publish(spec.getIn3(), in3, session); + + Message out = pollOrFail(outQueue, COMPLETE_PUBLISH_DEADLINE_MS, "Expected output within " + COMPLETE_PUBLISH_DEADLINE_MS + "ms of third publish"); + long outDeltaMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - thirdPublishTime); + assertTrue(outDeltaMs <= COMPLETE_PUBLISH_DEADLINE_MS, "Output was not immediate; deltaMs=" + outDeltaMs); + + Envelope envelope = parseEnvelope(out); + assertEquals("new", envelope.getPayloadField(spec.getIn1(), "value"), "LAST did not win for in1"); + assertEquals("v2", envelope.getPayloadField(spec.getIn2(), "value")); + assertEquals("v3", envelope.getPayloadField(spec.getIn3(), "value")); + } finally { + closeSubscription(sub, session); + closeSession(session); + } + } + + private void onMessage(BlockingQueue outQueue, String runId, MessageEvent messageEvent) { + try { + Message message = messageEvent.getMessage(); + if (message == null) { + return; + } + + if (!JSON_CONTENT_TYPE.equals(message.getContentType())) { + return; + } + + byte[] bytes = message.getOpaqueData(); + if (bytes == null) { + return; + } + + String json = toUtf8(bytes); + if (!json.contains("\"runId\":\"" + runId + "\"")) { + return; + } + + outQueue.offer(message); + } finally { + Runnable completionTask = messageEvent.getCompletionTask(); + if (completionTask != null) { + completionTask.run(); + } + } + } + + private void drain(BlockingQueue queue, long durationMs) throws InterruptedException { + long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMs); + while (System.nanoTime() < end) { + Message ignored = queue.poll(10, TimeUnit.MILLISECONDS); + if (ignored == null) { + continue; + } + } + } + + private Message pollOrFail(BlockingQueue queue, long timeoutMs, String failureMessage) throws InterruptedException { + Message message = queue.poll(timeoutMs, TimeUnit.MILLISECONDS); + if (message == null) { + fail(failureMessage); + } + return message; + } + + private Message jsonMessage(String runId, String key1, String value1, String key2, String value2) { + String json = "{\"runId\":\"" + runId + "\",\"" + key1 + "\":\"" + value1 + "\",\"" + key2 + "\":\"" + value2 + "\"}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private Envelope parseEnvelope(Message outputMessage) { + assertNotNull(outputMessage.getOpaqueData(), "Output message had null payload"); + String json = toUtf8(outputMessage.getOpaqueData()); + + Map root = gson.fromJson(json, mapType); + Object inputsObj = root.get("inputs"); + assertNotNull(inputsObj, "Envelope missing 'inputs'"); + + @SuppressWarnings("unchecked") + Map inputs = (Map) inputsObj; + + return new Envelope(inputs); + } + + private static String toUtf8(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private static String newRunId() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorTransformationSystemTest.java b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorTransformationSystemTest.java new file mode 100644 index 000000000..c68aae9f4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/StaticAggregatorTransformationSystemTest.java @@ -0,0 +1,291 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.MessageEvent; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscribedEventManager; +import io.mapsmessaging.api.message.Message; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Transformation pipeline tests for aggregator-2. + * + * Current intent: + * - Publish messy JSON inputs on /aggregator2/in1, /in2, /in3. + * - Aggregator outputs the standard envelope JSON (for now). + * - Once inbound jsonMutate is configured, each input payload should be reduced to: + * in1: { "runId": "...", "temp": 20 } + * in2: { "runId": "...", "humidity": 60 } + * in3: { "runId": "...", "pressure": 990 } + * + * The test is expected to FAIL until transformers are configured. That is intentional. + */ +class StaticAggregatorTransformationSystemTest extends BaseAggreagtorTest { + + private static final long COMPLETE_PUBLISH_DEADLINE_MS = 500; + private static final String JSON_CONTENT_TYPE = "application/json"; + + private static final String OUT_TOPIC = "/aggregator2/out1"; + private static final String IN1 = "/aggregator2/in1"; + private static final String IN2 = "/aggregator2/in2"; + private static final String IN3 = "/aggregator2/in3"; + + private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + private final Type mapType = new TypeToken>() {}.getType(); + + @Test + void aggregator2_completePublishes_andInboundMutateProducesReducedFields() throws Exception { + + String runId = newRunId(); + BlockingQueue outQueue = new LinkedBlockingQueue<>(); + + MessageListener listener = messageEvent -> onOutputMessage(outQueue, runId, messageEvent); + + Session session = createSession("agg2-transform-" + runId, listener); + SubscribedEventManager outSub = addSubscription(OUT_TOPIC, session); + + try { + drain(outQueue, 200); + + Message tempMessy = messyTemperature(runId, 20); + Message humidityMessy = messyHumidity(runId, 60); + Message pressureMessy = messyPressure(runId, 990); + + publish(IN1, tempMessy, session); + publish(IN2, humidityMessy, session); + + publish(IN3, pressureMessy, session); + + Message out = pollOrFail(outQueue, COMPLETE_PUBLISH_DEADLINE_MS, + "Expected aggregation output within " + COMPLETE_PUBLISH_DEADLINE_MS + "ms of third publish"); + + assertNotNull(out.getOpaqueData(), "Output message had null payload"); + assertEquals(JSON_CONTENT_TYPE, out.getContentType(), "Output contentType must be application/json"); + + String outJson = toUtf8(out.getOpaqueData()); + + Map root = gson.fromJson(outJson, mapType); + Object inputsObj = root.get("inputs"); + assertNotNull(inputsObj, "Envelope missing 'inputs'. Output=" + pretty(outJson)); + + @SuppressWarnings("unchecked") + Map inputs = (Map) inputsObj; + + assertEquals(3, inputs.size(), "Expected exactly 3 inputs present. Output=" + pretty(outJson)); + assertTrue(inputs.containsKey(IN1), "Missing input entry for " + IN1 + ". Output=" + pretty(outJson)); + assertTrue(inputs.containsKey(IN2), "Missing input entry for " + IN2 + ". Output=" + pretty(outJson)); + assertTrue(inputs.containsKey(IN3), "Missing input entry for " + IN3 + ". Output=" + pretty(outJson)); + + // These are the transform assertions. They will FAIL until inbound jsonMutate is configured. + assertEquals(20.0, numberPayloadField(inputs, IN1, "temp", outJson), "in1 missing/incorrect 'temp'. Output=" + pretty(outJson)); + assertEquals(60.0, numberPayloadField(inputs, IN2, "humidity", outJson), "in2 missing/incorrect 'humidity'. Output=" + pretty(outJson)); + assertEquals(990.0, numberPayloadField(inputs, IN3, "pressure", outJson), "in3 missing/incorrect 'pressure'. Output=" + pretty(outJson)); + + // Keep runId as a stable filter signal until we decide to drop it later. + assertEquals(runId, stringPayloadField(inputs, IN1, "runId", outJson), "in1 missing/incorrect 'runId'. Output=" + pretty(outJson)); + assertEquals(runId, stringPayloadField(inputs, IN2, "runId", outJson), "in2 missing/incorrect 'runId'. Output=" + pretty(outJson)); + assertEquals(runId, stringPayloadField(inputs, IN3, "runId", outJson), "in3 missing/incorrect 'runId'. Output=" + pretty(outJson)); + } finally { + closeSubscription(outSub, session); + closeSession(session); + } + } + + private void onOutputMessage(BlockingQueue outQueue, String runId, MessageEvent messageEvent) { + try { + Message message = messageEvent.getMessage(); + if (message == null) { + return; + } + + if (!OUT_TOPIC.equals(messageEvent.getDestinationName())) { + return; + } + + if (!JSON_CONTENT_TYPE.equals(message.getContentType())) { + return; + } + + byte[] bytes = message.getOpaqueData(); + if (bytes == null) { + return; + } + + String json = toUtf8(bytes); + if (!json.contains("\"runId\":\"" + runId + "\"")) { + return; + } + + outQueue.offer(message); + } finally { + Runnable completionTask = messageEvent.getCompletionTask(); + if (completionTask != null) { + completionTask.run(); + } + } + } + + private void drain(BlockingQueue queue, long durationMs) throws InterruptedException { + long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMs); + while (System.nanoTime() < end) { + Message ignored = queue.poll(10, TimeUnit.MILLISECONDS); + if (ignored == null) { + continue; + } + } + } + + private Message pollOrFail(BlockingQueue queue, long timeoutMs, String failureMessage) throws InterruptedException { + Message message = queue.poll(timeoutMs, TimeUnit.MILLISECONDS); + if (message == null) { + fail(failureMessage); + } + return message; + } + + private Message messyTemperature(String runId, int temperatureC) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"deviceId\":\"dev-001\"," + + "\"deviceName\":\"temp-sensor\"," + + "\"time\":1700000000000," + + "\"location\":{\"lat\":-33.86,\"lon\":151.21}," + + "\"battery\":{\"pct\":87}," + + "\"reading\":{\"temperatureC\":" + temperatureC + "}" + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private Message messyHumidity(String runId, int humidityPct) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"deviceId\":\"dev-002\"," + + "\"deviceName\":\"humidity-sensor\"," + + "\"time\":1700000000100," + + "\"location\":{\"lat\":-33.86,\"lon\":151.21}," + + "\"signal\":{\"rssi\":-61}," + + "\"reading\":{\"humidityPct\":" + humidityPct + "}" + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private Message messyPressure(String runId, int pressureHpa) { + String json = + "{" + + "\"runId\":\"" + runId + "\"," + + "\"deviceId\":\"dev-003\"," + + "\"deviceName\":\"pressure-sensor\"," + + "\"time\":1700000000200," + + "\"location\":{\"lat\":-33.86,\"lon\":151.21}," + + "\"calibration\":{\"offset\":2}," + + "\"reading\":{\"pressureHpa\":" + pressureHpa + "}" + + "}"; + + MessageBuilder builder = new MessageBuilder(); + builder.setContentType(JSON_CONTENT_TYPE); + builder.setOpaqueData(json.getBytes(StandardCharsets.UTF_8)); + return builder.build(); + } + + private double numberPayloadField(Map inputs, String inputTopic, String fieldName, String fullOutputJson) { + Map payload = payloadObject(inputs, inputTopic, fullOutputJson); + + Object value = payload.get(fieldName); + assertNotNull(value, "Missing payload field '" + fieldName + "' for topic " + inputTopic + ". Output=" + pretty(fullOutputJson)); + + if (value instanceof Number number) { + return number.doubleValue(); + } + fail("Payload field '" + fieldName + "' for topic " + inputTopic + " was not a number (was " + value.getClass().getName() + "). Output=" + pretty(fullOutputJson)); + return 0; + } + + private String stringPayloadField(Map inputs, String inputTopic, String fieldName, String fullOutputJson) { + Map payload = payloadObject(inputs, inputTopic, fullOutputJson); + + Object value = payload.get(fieldName); + assertNotNull(value, "Missing payload field '" + fieldName + "' for topic " + inputTopic + ". Output=" + pretty(fullOutputJson)); + + if (value instanceof String str) { + return str; + } + fail("Payload field '" + fieldName + "' for topic " + inputTopic + " was not a string (was " + value.getClass().getName() + "). Output=" + pretty(fullOutputJson)); + return null; + } + + private Map payloadObject(Map inputs, String inputTopic, String fullOutputJson) { + Object entryObj = inputs.get(inputTopic); + assertNotNull(entryObj, "Missing envelope entry for topic " + inputTopic + ". Output=" + pretty(fullOutputJson)); + + @SuppressWarnings("unchecked") + Map entry = (Map) entryObj; + + Object payloadObj = entry.get("payload"); + assertNotNull(payloadObj, "Expected 'payload' object for topic " + inputTopic + ". Output=" + pretty(fullOutputJson)); + + @SuppressWarnings("unchecked") + Map payload = (Map) payloadObj; + + return payload; + } + + private String pretty(String json) { + try { + Object parsed = gson.fromJson(json, Object.class); + return new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(parsed); + } catch (Exception e) { + return json; + } + } + + private static String toUtf8(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private static String newRunId() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/test/java/io/mapsmessaging/aggregator/mailbox/QueueBackedMpscMailboxTest.java b/src/test/java/io/mapsmessaging/aggregator/mailbox/QueueBackedMpscMailboxTest.java new file mode 100644 index 000000000..07f310faf --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/mailbox/QueueBackedMpscMailboxTest.java @@ -0,0 +1,208 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator.mailbox; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class QueueBackedMpscMailboxTest { + + @Test + void offerHonoursCapacity() { + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(3); + + assertTrue(mailbox.offer(1)); + assertTrue(mailbox.offer(2)); + assertTrue(mailbox.offer(3)); + assertFalse(mailbox.offer(4)); + } + + @Test + void drainToHonoursMaxBatch() { + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(10); + + assertTrue(mailbox.offer(1)); + assertTrue(mailbox.offer(2)); + assertTrue(mailbox.offer(3)); + assertTrue(mailbox.offer(4)); + + List drained = new ArrayList<>(); + int drainedCount = mailbox.drainTo(drained::add, 2); + + assertEquals(2, drainedCount); + assertEquals(List.of(1, 2), drained); + + drained.clear(); + drainedCount = mailbox.drainTo(drained::add, 10); + + assertEquals(2, drainedCount); + assertEquals(List.of(3, 4), drained); + } + + @Test + void concurrentProducersNoLoss() throws Exception { + int producerCount = 8; + int perProducer = 5000; + int total = producerCount * perProducer; + + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(total + 10); + + ExecutorService producers = Executors.newFixedThreadPool(producerCount); + CountDownLatch startLatch = new CountDownLatch(1); + + for (int producerIndex = 0; producerIndex < producerCount; producerIndex++) { + int base = producerIndex * perProducer; + producers.submit(() -> { + startLatch.await(); + for (int i = 0; i < perProducer; i++) { + boolean accepted = mailbox.offer(base + i); + if (!accepted) { + fail("Mailbox rejected item unexpectedly; capacity too small or offer semantics incorrect"); + } + } + return null; + }); + } + + startLatch.countDown(); + producers.shutdown(); + assertTrue(producers.awaitTermination(10, TimeUnit.SECONDS)); + + AtomicInteger drainedCount = new AtomicInteger(0); + + int pass; + do { + pass = mailbox.drainTo(value -> drainedCount.incrementAndGet(), 2048); + } while (pass > 0); + + assertEquals(total, drainedCount.get()); + } + + @Test + public void drainToOnEmptyReturnsZeroAndDoesNotInvokeConsumer() { + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(10); + + AtomicInteger calls = new AtomicInteger(0); + int drained = mailbox.drainTo(v -> calls.incrementAndGet(), 10); + + assertEquals(0, drained); + assertEquals(0, calls.get()); + } + + @Test + public void drainToMaxBatchZeroDrainsNothing() { + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(10); + + assertTrue(mailbox.offer(1)); + assertTrue(mailbox.offer(2)); + + List drained = new ArrayList<>(); + int count = mailbox.drainTo(drained::add, 0); + + assertEquals(0, count); + assertTrue(drained.isEmpty()); + + // items should still be there + count = mailbox.drainTo(drained::add, 10); + assertEquals(2, count); + assertEquals(List.of(1, 2), drained); + } + + @Test + public void singleProducerIsFifo() { + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(100); + + for (int i = 1; i <= 50; i++) { + assertTrue(mailbox.offer(i)); + } + + List drained = new ArrayList<>(); + int count = mailbox.drainTo(drained::add, 100); + + assertEquals(50, count); + for (int i = 1; i <= 50; i++) { + assertEquals(i, drained.get(i - 1)); + } + } + + @Test + void interleavedDrainDoesNotLoseItems() throws Exception { + int total = 20_000; + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(total + 10); + + ExecutorService producer = Executors.newSingleThreadExecutor(); + CountDownLatch startLatch = new CountDownLatch(1); + + producer.submit(() -> { + startLatch.await(); + for (int i = 0; i < total; i++) { + if (!mailbox.offer(i)) { + fail("Unexpected rejection"); + } + } + return null; + }); + + startLatch.countDown(); + + AtomicInteger drainedCount = new AtomicInteger(0); + while (drainedCount.get() < total) { + int pass = mailbox.drainTo(v -> drainedCount.incrementAndGet(), 123); + if (pass == 0) { + Thread.yield(); + } + } + + producer.shutdown(); + assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(total, drainedCount.get()); + } + + @Test + void sizeAndOfferedCountSingleThreaded() { + int capacity = 10; + QueueBackedMpscMailbox mailbox = new QueueBackedMpscMailbox<>(capacity); + + assertEquals(0, mailbox.size()); + assertEquals(capacity, mailbox.capacity()); + assertEquals(0, mailbox.getOfferedCount()); + + assertTrue(mailbox.offer(1)); + assertTrue(mailbox.offer(2)); + assertTrue(mailbox.offer(3)); + + assertEquals(3, mailbox.size()); + assertEquals(3, mailbox.getOfferedCount()); + + List drained = new ArrayList<>(); + int drainedCount = mailbox.drainTo(drained::add, 10); + + assertEquals(3, drainedCount); + assertEquals(0, mailbox.size()); + assertEquals(3, mailbox.getOfferedCount()); // offered count should NOT decrease + } + +} diff --git a/src/test/java/io/mapsmessaging/aggregator/worker/StaticAggregatorWorkSchedulerTest.java b/src/test/java/io/mapsmessaging/aggregator/worker/StaticAggregatorWorkSchedulerTest.java new file mode 100644 index 000000000..0c91147f1 --- /dev/null +++ b/src/test/java/io/mapsmessaging/aggregator/worker/StaticAggregatorWorkSchedulerTest.java @@ -0,0 +1,180 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.aggregator.worker; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticAggregatorWorkSchedulerTest { + + @Test + void stripeCountZeroResolvesToPositive() { + AggregatorWorkScheduler scheduler = new AggregatorWorkScheduler(0, 10, 1); + assertNotNull(scheduler); + } + + @Test + void signalTriggersDrainAndIsIdempotent() throws Exception { + AggregatorWorkScheduler scheduler = new AggregatorWorkScheduler(1, 10, 1); + FakeWorkItem workItem = new FakeWorkItem("A", 100); + + scheduler.register(workItem); + scheduler.start(); + + try { + for (int i = 0; i < 1000; i++) { + scheduler.signal(workItem); + } + + assertTrue(workItem.awaitDrained(100, 2, TimeUnit.SECONDS), "Work item did not drain"); + assertEquals(100, workItem.totalDrained.get(), "Expected all work to be drained"); + + int drainCalls = workItem.drainCalls.get(); + Thread.sleep(50); + assertTrue(drainCalls <= 20, "Too many drain calls, scheduler is thrashing: " + drainCalls); + } finally { + scheduler.stop(); + } + } + + @Test + void maxBatchLimitsDrainAndResignals() throws Exception { + AggregatorWorkScheduler scheduler = new AggregatorWorkScheduler(1, 7, 1); + FakeWorkItem workItem = new FakeWorkItem("B", 50); + + scheduler.register(workItem); + scheduler.start(); + + try { + scheduler.signal(workItem); + + assertTrue(workItem.awaitDrained(50, 2, TimeUnit.SECONDS), "Work item did not drain enough"); + assertTrue(workItem.drainCalls.get() >= 8, "Expected multiple drain calls due to maxBatch"); + assertEquals(50, workItem.totalDrained.get()); + } finally { + scheduler.stop(); + } + } + + @Test + void timeoutTickCallsCheckTimeout() throws Exception { + AggregatorWorkScheduler scheduler = new AggregatorWorkScheduler(1, 10, 1); + FakeWorkItem workItem = new FakeWorkItem("C", 0); + + scheduler.register(workItem); + scheduler.start(); + + try { + long start = System.currentTimeMillis(); + while (workItem.timeoutCalls.get() == 0 && (System.currentTimeMillis() - start) < 500) { + Thread.sleep(10); + } + assertTrue(workItem.timeoutCalls.get() > 0, "Expected checkTimeout() to be called by timeout tick"); + } finally { + scheduler.stop(); + } + } + + private static class FakeWorkItem implements AggregatorWorkItem { + + private final String name; + private final AtomicInteger remainingWork; + private final AtomicInteger totalDrained; + private final AtomicInteger drainCalls; + private final AtomicInteger timeoutCalls; + private final AtomicInteger scheduled; + private final CountDownLatch drainedLatch; + + private final AtomicLong lastDrainMillis; + + private FakeWorkItem(String name, int initialWork) { + this.name = name; + this.remainingWork = new AtomicInteger(initialWork); + this.totalDrained = new AtomicInteger(0); + this.drainCalls = new AtomicInteger(0); + this.timeoutCalls = new AtomicInteger(0); + this.scheduled = new AtomicInteger(0); + this.drainedLatch = new CountDownLatch(initialWork == 0 ? 0 : 1); + this.lastDrainMillis = new AtomicLong(0); + } + + @Override + public String getName() { + return name; + } + + @Override + public int drainOnce(int maxBatch) { + drainCalls.incrementAndGet(); + lastDrainMillis.set(System.currentTimeMillis()); + + int drained = 0; + + for (int i = 0; i < maxBatch; i++) { + int remaining = remainingWork.get(); + if (remaining <= 0) { + break; + } + if (remainingWork.compareAndSet(remaining, remaining - 1)) { + drained++; + } + } + + if (drained > 0) { + totalDrained.addAndGet(drained); + } + + if (remainingWork.get() == 0 && drainedLatch.getCount() > 0) { + drainedLatch.countDown(); + } + + return drained; + } + + @Override + public void checkTimeout() { + timeoutCalls.incrementAndGet(); + } + + @Override + public boolean tryMarkScheduled() { + return scheduled.compareAndSet(0, 1); + } + + @Override + public void clearScheduled() { + scheduled.set(0); + } + + private boolean awaitDrained(int expected, long timeout, TimeUnit unit) throws InterruptedException { + if (expected == 0) { + return true; + } + boolean ok = drainedLatch.await(timeout, unit); + return ok && totalDrained.get() == expected; + } + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/AdvancedStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/AdvancedStatisticsTest.java new file mode 100644 index 000000000..ebd015213 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/AdvancedStatisticsTest.java @@ -0,0 +1,108 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AdvancedStatisticsTest { + + @Test + void reset_clearsSubclassDerivedValuesInJson() { + AdvancedStatistics stats = new AdvancedStatistics(); + stats.reset(); + + JsonObject json = stats.toJson(); + assertEquals(0.0, json.get("stdDev").getAsDouble(), 0.0); + assertEquals(0.0, json.get("slope").getAsDouble(), 0.0); + assertEquals(0.0, json.get("intercept").getAsDouble(), 0.0); + } + + @Test + void stdDev_isZeroForFewerThanTwoSamples() { + AdvancedStatistics stats = new AdvancedStatistics(); + stats.update(5); + + assertEquals(0.0, stats.getStdDeviation(), 0.0); + JsonObject json = stats.toJson(); + assertEquals(0.0, json.get("stdDev").getAsDouble(), 0.0); + } + + @Test + void stdDev_matchesSampleStdDev() { + AdvancedStatistics stats = new AdvancedStatistics(); + // values: 2, 4, 4, 4, 5, 5, 7, 9 + // sample std dev = sqrt( (sum (xi-xbar)^2) / (n-1) ) = sqrt(32/7) + double[] values = {2, 4, 4, 4, 5, 5, 7, 9}; + for (double value : values) { + stats.update(value); + } + + double expected = Math.sqrt(32.0 / 7.0); + assertEquals(expected, stats.getStdDeviation(), 1e-12); + + JsonObject json = stats.toJson(); + assertEquals(expected, json.get("stdDev").getAsDouble(), 1e-12); + } + + @Test + void regression_slopeAndIntercept_areCorrectForPerfectLine_overSampleIndex() { + AdvancedStatistics stats = new AdvancedStatistics(); + // time = count (1..n). Use y = 2*t + 1 + for (int index = 1; index <= 5; index++) { + double y = 2.0 * index + 1.0; + stats.update(y); + } + + assertEquals(2.0, stats.getSlope(), 1e-12); + assertEquals(1.0, stats.getIntercept(), 1e-12); + + JsonObject json = stats.toJson(); + assertEquals(2.0, json.get("slope").getAsDouble(), 1e-12); + assertEquals(1.0, json.get("intercept").getAsDouble(), 1e-12); + } + + @Test + void regression_returnsZeroForFewerThanTwoSamples() { + AdvancedStatistics stats = new AdvancedStatistics(); + stats.update(123); + + assertEquals(0.0, stats.getSlope(), 0.0); + assertEquals(0.0, stats.getIntercept(), 0.0); + } + + @Test + void create_returnsNewInstanceWithCleanState() { + AdvancedStatistics stats = new AdvancedStatistics(); + stats.update(123); + + Statistics created = stats.create(); + assertNotNull(created); + assertInstanceOf(AdvancedStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0.0, json.get("stdDev").getAsDouble(), 0.0); + assertEquals(0.0, json.get("slope").getAsDouble(), 0.0); + assertEquals(0.0, json.get("intercept").getAsDouble(), 0.0); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/BaseStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/BaseStatisticsTest.java new file mode 100644 index 000000000..2befa400b --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/BaseStatisticsTest.java @@ -0,0 +1,129 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class BaseStatisticsTest { + + @Test + void reset_setsDefaults() { + BaseStatistics stats = new BaseStatistics(); + stats.update(42); + stats.incrementMismatch(); + + stats.reset(); + JsonObject json = stats.toJson(); + + assertTrue(Double.isNaN(json.get("first").getAsDouble())); + assertTrue(Double.isNaN(json.get("last").getAsDouble())); + + assertEquals(Double.MAX_VALUE, json.get("min").getAsDouble(), 0.0); + assertEquals(Double.NEGATIVE_INFINITY, json.get("max").getAsDouble(), 0.0); + + assertEquals(0.0, json.get("average").getAsDouble(), 0.0); + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0, json.get("mismatched").getAsInt()); + + assertEquals(0L, json.get("firstUpdateMillis").getAsLong()); + assertEquals(0L, json.get("lastUpdateMillis").getAsLong()); + } + + @Test + void update_ignoresNonNumbers() { + BaseStatistics stats = new BaseStatistics(); + stats.update("nope"); + stats.update(null); + + JsonObject json = stats.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertTrue(Double.isNaN(json.get("first").getAsDouble())); + assertTrue(Double.isNaN(json.get("last").getAsDouble())); + } + + @Test + void update_tracksFirstLastMinMaxAverageCount_andTimestamps() throws Exception { + BaseStatistics stats = new BaseStatistics(); + + stats.update(10); + Thread.sleep(2); + stats.update(20); + Thread.sleep(2); + stats.update(30); + + JsonObject json = stats.toJson(); + + assertEquals(10.0, json.get("first").getAsDouble(), 1e-12); + assertEquals(30.0, json.get("last").getAsDouble(), 1e-12); + assertEquals(10.0, json.get("min").getAsDouble(), 1e-12); + assertEquals(30.0, json.get("max").getAsDouble(), 1e-12); + assertEquals(20.0, json.get("average").getAsDouble(), 1e-12); + assertEquals(3, json.get("count").getAsInt()); + + long firstTs = json.get("firstUpdateMillis").getAsLong(); + long lastTs = json.get("lastUpdateMillis").getAsLong(); + assertTrue(firstTs > 0L); + assertTrue(lastTs >= firstTs); + } + + @Test + void update_handlesNegativeNumbers_includingMaxInitialization() { + BaseStatistics stats = new BaseStatistics(); + + stats.update(-5); + stats.update(-10); + + JsonObject json = stats.toJson(); + assertEquals(-5.0, json.get("first").getAsDouble(), 1e-12); + assertEquals(-10.0, json.get("last").getAsDouble(), 1e-12); + assertEquals(-10.0, json.get("min").getAsDouble(), 1e-12); + assertEquals(-5.0, json.get("max").getAsDouble(), 1e-12); + assertEquals(2, json.get("count").getAsInt()); + assertEquals((-5.0 + -10.0) / 2.0, json.get("average").getAsDouble(), 1e-12); + } + + @Test + void incrementMismatch_incrementsAndIsExposedInJson() { + BaseStatistics stats = new BaseStatistics(); + + stats.incrementMismatch(); + stats.incrementMismatch(); + + JsonObject json = stats.toJson(); + assertEquals(2, json.get("mismatched").getAsInt()); + } + + @Test + void create_returnsNewInstanceWithCleanState() { + BaseStatistics stats = new BaseStatistics(); + stats.update(123); + + Statistics created = stats.create(); + assertNotNull(created); + assertInstanceOf(BaseStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertTrue(Double.isNaN(json.get("first").getAsDouble())); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsReferenceTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsReferenceTest.java new file mode 100644 index 000000000..2066673e5 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsReferenceTest.java @@ -0,0 +1,108 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MomentStatisticsReferenceTest { + + @Test + void skewnessAndKurtosis_matchDirectCentralMomentReference() { + double[] values = {1, 2, 2, 3, 9}; + + MomentStatistics stats = new MomentStatistics(); + for (double v : values) { + stats.update(v); + } + + ReferenceMoments ref = ReferenceMoments.compute(values); + assertEquals(ref.sampleSkewness, stats.getSampleSkewness(), 1e-3); + assertEquals(ref.sampleKurtosisExcess, stats.getSampleKurtosisExcess(), 1e-3); + } + + @Test + void symmetricDiscreteData_hasZeroSkewness_andNegativeExcessKurtosis() { + double[] values = {-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0}; + + MomentStatistics stats = new MomentStatistics(); + for (double v : values) { + stats.update(v); + } + + ReferenceMoments ref = ReferenceMoments.compute(values); + + // Symmetry should give exact (or extremely close) zero skewness. + assertEquals(ref.sampleSkewness, stats.getSampleSkewness(), 1e-12); + + // This dataset is NOT normal, so excess kurtosis is expected to be negative. + assertEquals(ref.sampleKurtosisExcess, stats.getSampleKurtosisExcess(), 1e-12); + } + + private static final class ReferenceMoments { + private final double sampleSkewness; + private final double sampleKurtosisExcess; + + private ReferenceMoments(double sampleSkewness, double sampleKurtosisExcess) { + this.sampleSkewness = sampleSkewness; + this.sampleKurtosisExcess = sampleKurtosisExcess; + } + + static ReferenceMoments compute(double[] x) { + int n = x.length; + if (n == 0) { + return new ReferenceMoments(Double.NaN, Double.NaN); + } + + double mean = 0.0; + for (double v : x) { + mean += v; + } + mean /= n; + + double m2 = 0.0; + double m3 = 0.0; + double m4 = 0.0; + + for (double v : x) { + double d = v - mean; + double d2 = d * d; + m2 += d2; + m3 += d2 * d; + m4 += d2 * d2; + } + + double skew = 0.0; + if (n >= 3 && m2 != 0.0) { + skew = (Math.sqrt((double) n * (n - 1.0)) / (n - 2.0)) * (m3 / Math.pow(m2, 1.5)); + } + + double kurtExcess = 0.0; + if (n >= 4 && m2 != 0.0) { + kurtExcess = + ((n * (n + 1.0) * m4) / (m2 * m2 * (n - 1.0) * (n - 2.0) * (n - 3.0))) + - (3.0 * (n - 1.0) * (n - 1.0) / ((n - 2.0) * (n - 3.0))); + } + + return new ReferenceMoments(skew, kurtExcess); + } + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsTest.java new file mode 100644 index 000000000..858f075ce --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/MomentStatisticsTest.java @@ -0,0 +1,98 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MomentStatisticsTest { + + @Test + void reset_clearsSkewnessAndKurtosisInJson() { + MomentStatistics stats = new MomentStatistics(); + stats.update(1); + stats.update(2); + stats.update(3); + + stats.reset(); + JsonObject json = stats.toJson(); + + assertEquals(0.0, json.get("skewness").getAsDouble(), 0.0); + assertEquals(0.0, json.get("kurtosisExcess").getAsDouble(), 0.0); + } + + @Test + void skewness_isZeroForFewerThanThreeSamples_orZeroVariance() { + MomentStatistics stats = new MomentStatistics(); + + stats.update(5); + stats.update(6); + assertEquals(0.0, stats.getSampleSkewness(), 0.0); + + stats.reset(); + stats.update(7); + stats.update(7); + stats.update(7); + assertEquals(0.0, stats.getSampleSkewness(), 0.0); + } + + @Test + void kurtosisExcess_isZeroForFewerThanFourSamples() { + MomentStatistics stats = new MomentStatistics(); + stats.update(1); + stats.update(2); + stats.update(3); + + assertEquals(0.0, stats.getSampleKurtosisExcess(), 0.0); + } + + @Test + void symmetricData_hasNearZeroSkewness() { + MomentStatistics stats = new MomentStatistics(); + // symmetric around 0 + double[] values = {-2, -1, 0, 1, 2}; + for (double v : values) { + stats.update(v); + } + + assertEquals(0.0, stats.getSampleSkewness(), 1e-12); + + JsonObject json = stats.toJson(); + assertEquals(0.0, json.get("skewness").getAsDouble(), 1e-12); + } + + @Test + void create_returnsNewMomentStatisticsWithCleanState() { + MomentStatistics stats = new MomentStatistics(); + stats.update(1); + stats.update(2); + stats.update(3); + Statistics created = stats.create(); + + assertInstanceOf(MomentStatistics.class, created); + JsonObject json = created.toJson(); + + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0.0, json.get("skewness").getAsDouble(), 0.0); + assertEquals(0.0, json.get("kurtosisExcess").getAsDouble(), 0.0); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/MovingAverageStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/MovingAverageStatisticsTest.java new file mode 100644 index 000000000..3b8f1e5ee --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/MovingAverageStatisticsTest.java @@ -0,0 +1,70 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MovingAverageStatisticsTest { + + @Test + void update_populatesBaseStatsAndAddsMovingAverageFieldsToJson() { + MovingAverageStatistics stats = new MovingAverageStatistics(); + + stats.update(10); + stats.update(20); + + JsonObject json = stats.toJson(); + assertEquals(2, json.get("count").getAsInt()); + assertEquals(10.0, json.get("first").getAsDouble(), 1e-12); + assertEquals(20.0, json.get("last").getAsDouble(), 1e-12); + + assertTrue(json.has("1m")); + assertTrue(json.has("5m")); + assertTrue(json.has("10m")); + assertTrue(json.has("15m")); + } + + @Test + void reset_doesNotThrow_andClearsBaseStats() { + MovingAverageStatistics stats = new MovingAverageStatistics(); + stats.update(10); + stats.update(20); + + stats.reset(); + JsonObject json = stats.toJson(); + + assertEquals(0, json.get("count").getAsInt()); + assertTrue(Double.isNaN(json.get("first").getAsDouble())); + assertTrue(json.has("1m")); + assertTrue(json.has("15m")); + } + + @Test + void create_returnsNewInstance() { + MovingAverageStatistics stats = new MovingAverageStatistics(); + Statistics created = stats.create(); + + assertInstanceOf(MovingAverageStatistics.class, created); + assertEquals(0, created.toJson().get("count").getAsInt()); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/QualityStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/QualityStatisticsTest.java new file mode 100644 index 000000000..236397f90 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/QualityStatisticsTest.java @@ -0,0 +1,97 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class QualityStatisticsTest { + + @Test + void update_nanAndInfinite_incrementCountersAndDoNotAffectCount() { + QualityStatistics stats = new QualityStatistics(); + + stats.update(Double.NaN); + stats.update(Double.POSITIVE_INFINITY); + stats.update(Double.NEGATIVE_INFINITY); + stats.update(1.0); + + JsonObject json = stats.toJson(); + assertEquals(1, json.get("count").getAsInt()); + assertEquals(1L, json.get("nan").getAsLong()); + assertEquals(2L, json.get("infinite").getAsLong()); + } + + @Test + void updateMissing_incrementsMissingCount_andShowsInJson() { + QualityStatistics stats = new QualityStatistics(); + stats.updateMissing(); + stats.updateMissing(); + + JsonObject json = stats.toJson(); + assertEquals(2L, json.get("missing").getAsLong()); + } + + @Test + void setOutlierStdDevs_isClampedToNonNegative() { + QualityStatistics stats = new QualityStatistics(); + + stats.setOutlierStdDevs(-5.0); + JsonObject json = stats.toJson(); + assertEquals(0.0, json.get("outlierStdDevs").getAsDouble(), 0.0); + } + + @Test + void outlierDetection_countsObviousOutlier_whenThresholdIsStrict() { + QualityStatistics stats = new QualityStatistics(); + stats.setOutlierStdDevs(0.5); // make it strict + + // Build some baseline variance then inject a big jump + for (int i = 0; i < 20; i++) { + stats.update(100.0 + (i % 2)); // 100,101,100,101... + } + long before = stats.getOutlierCount(); + + stats.update(1000.0); + long after = stats.getOutlierCount(); + + assertTrue(after >= before + 1, "Expected outlier count to increase"); + } + + @Test + void create_returnsNewQualityStatisticsWithCleanState() { + QualityStatistics stats = new QualityStatistics(); + stats.updateMissing(); + stats.update(Double.NaN); + stats.update(1.0); + + Statistics created = stats.create(); + assertInstanceOf(QualityStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0L, json.get("missing").getAsLong()); + assertEquals(0L, json.get("nan").getAsLong()); + assertEquals(0L, json.get("infinite").getAsLong()); + assertEquals(0L, json.get("outliers").getAsLong()); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/QuantileStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/QuantileStatisticsTest.java new file mode 100644 index 000000000..d5a65b929 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/QuantileStatisticsTest.java @@ -0,0 +1,78 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class QuantileStatisticsTest { + + @Test + void quantiles_areNaNBeforeAnySamples() { + QuantileStatistics stats = new QuantileStatistics(); + assertTrue(Double.isNaN(stats.getMedian())); + assertTrue(Double.isNaN(stats.getP90())); + } + + @Test + void quantiles_existInJson_afterSomeSamples() { + QuantileStatistics stats = new QuantileStatistics(); + for (int i = 1; i <= 5; i++) { + stats.update(i); + } + + JsonObject json = stats.toJson(); + assertTrue(json.has("median")); + assertTrue(json.has("p90")); + assertTrue(json.has("p95")); + assertTrue(json.has("p99")); + } + + @Test + void median_isReasonableForOrderedSequence() { + QuantileStatistics stats = new QuantileStatistics(); + for (int i = 1; i <= 101; i++) { + stats.update(i); + } + + // True median is 51 for 1..101 + double median = stats.getMedian(); + assertFalse(Double.isNaN(median)); + assertEquals(51.0, median, 2.0); // P² is approximate; give it room to breathe + } + + @Test + void create_returnsNewQuantileStatisticsWithCleanState() { + QuantileStatistics stats = new QuantileStatistics(); + for (int i = 1; i <= 10; i++) { + stats.update(i); + } + + Statistics created = stats.create(); + assertInstanceOf(QuantileStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertTrue(Double.isNaN(((QuantileStatistics) created).getMedian())); + } +} + diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/StatisticsTests.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/StatisticsTests.java index 7eac3ccac..1a53429d6 100644 --- a/src/test/java/io/mapsmessaging/analytics/impl/stats/StatisticsTests.java +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/StatisticsTests.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -54,7 +54,7 @@ void advanced_basicAndRegression() { assertEquals(-1.0, stats.getIntercept(), 1e-12); JsonObject json = stats.toJson(); - assertJsonHas(json, "first","last","min","max","average","count","range","delta","stdDev","slope","intercept"); + assertJsonHas(json, "first","last","min","max","average","count","stdDev","slope","intercept"); assertEquals(499.5, json.get("average").getAsDouble(), 1e-12); } diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/StringStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/StringStatisticsTest.java new file mode 100644 index 000000000..25557a739 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/StringStatisticsTest.java @@ -0,0 +1,109 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class StringStatisticsTest { + + @Test + void update_countsTrimmedStringsAndNumbers() { + StringStatistics stats = new StringStatistics(); + stats.reset(); + + stats.update(" apple "); + stats.update("apple"); + stats.update(123); + stats.update(123); + stats.update(null); + stats.update(new Object()); // ignored + + JsonObject json = stats.toJson(); + assertEquals(4L, json.get("totalCount").getAsLong()); + } + + @Test + void toJson_distributionIsSortedByCountDescending() { + StringStatistics stats = new StringStatistics(); + stats.reset(); + + stats.update("a"); + stats.update("b"); + stats.update("b"); + stats.update("c"); + stats.update("c"); + stats.update("c"); + + JsonObject json = stats.toJson(); + JsonArray dist = json.getAsJsonArray("distribution"); + + assertEquals("c", dist.get(0).getAsJsonObject().get("value").getAsString()); + assertEquals(3L, dist.get(0).getAsJsonObject().get("count").getAsLong()); + + assertEquals("b", dist.get(1).getAsJsonObject().get("value").getAsString()); + assertEquals(2L, dist.get(1).getAsJsonObject().get("count").getAsLong()); + + assertEquals("a", dist.get(2).getAsJsonObject().get("value").getAsString()); + assertEquals(1L, dist.get(2).getAsJsonObject().get("count").getAsLong()); + } + + @Test + void incrementMismatch_incrementsAndAppearsInJson() { + StringStatistics stats = new StringStatistics(); + stats.reset(); + + stats.incrementMismatch(); + stats.incrementMismatch(); + + JsonObject json = stats.toJson(); + assertEquals(2, json.get("mismatched").getAsInt()); + } + + @Test + void reset_clearsCountsAndMismatch() { + StringStatistics stats = new StringStatistics(); + stats.update("x"); + stats.incrementMismatch(); + + stats.reset(); + JsonObject json = stats.toJson(); + + assertEquals(0L, json.get("totalCount").getAsLong()); + assertEquals(0, json.get("mismatched").getAsInt()); + assertEquals(0, json.getAsJsonArray("distribution").size()); + } + + @Test + void create_returnsNewInstanceWithCleanState() { + StringStatistics stats = new StringStatistics(); + stats.update("x"); + + Statistics created = stats.create(); + assertInstanceOf(StringStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0L, json.get("totalCount").getAsLong()); + assertEquals(0, json.getAsJsonArray("distribution").size()); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/TimeWindowMovingAverageTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/TimeWindowMovingAverageTest.java new file mode 100644 index 000000000..8b95d8d2c --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/TimeWindowMovingAverageTest.java @@ -0,0 +1,73 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class TimeWindowMovingAverageTest { + + @Test + void name_isTimeAndUnit() { + TimeWindowMovingAverage avg = new TimeWindowMovingAverage(5, TimeUnit.MINUTES); + assertEquals("5_MINUTES", avg.getName()); + } + + @Test + void average_isZeroWhenEmpty() { + TimeWindowMovingAverage avg = new TimeWindowMovingAverage(1, TimeUnit.SECONDS); + assertEquals(0.0, avg.getAverage(), 0.0); + } + + @Test + void add_includesValuesInAverageUntilExpired() throws Exception { + TimeWindowMovingAverage avg = new TimeWindowMovingAverage(5, TimeUnit.MILLISECONDS); + + avg.add(10); + avg.add(20); + assertEquals(15.0, avg.getAverage(), 1e-12); + + // Wait for expiry + Thread.sleep(10); + avg.update(); + assertEquals(0.0, avg.getAverage(), 0.0); + } + + @Test + void reset_clearsState() { + TimeWindowMovingAverage avg = new TimeWindowMovingAverage(1, TimeUnit.SECONDS); + + avg.add(10); + avg.add(20); + assertEquals(15.0, avg.getAverage(), 1e-12); + + avg.reset(); + assertEquals(0.0, avg.getAverage(), 0.0); + } + + @Test + void update_doesNotThrow_whenEmpty() { + TimeWindowMovingAverage avg = new TimeWindowMovingAverage(1, TimeUnit.SECONDS); + assertDoesNotThrow(avg::update); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/TrendStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/TrendStatisticsTest.java new file mode 100644 index 000000000..cbfbefffd --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/TrendStatisticsTest.java @@ -0,0 +1,119 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TrendStatisticsTest { + + @Test + void timestampRegression_isZeroUntilAtLeastTwoTimestampSamplesExist() { + TrendStatistics stats = new TrendStatistics(); + + stats.update(1_000_000_000L, 3.0); // one sample + + assertEquals(0.0, stats.getTimestampSlopePerSecond(), 0.0); + assertEquals(0.0, stats.getTimestampIntercept(), 0.0); + + JsonObject json = stats.toJson(); + assertEquals(0.0, json.get("timestampSlopePerSec").getAsDouble(), 0.0); + assertEquals(0.0, json.get("timestampIntercept").getAsDouble(), 0.0); + } + + @Test + void timestampRegression_matchesPerfectLine_withExplicitTimestamps() { + TrendStatistics stats = new TrendStatistics(); + + // y = 2*t + 1, t in seconds; timestamps in nanos + stats.update(1_000_000_000L, 3.0); // t=1 + stats.update(2_000_000_000L, 5.0); // t=2 + stats.update(3_000_000_000L, 7.0); // t=3 + + assertEquals(2.0, stats.getTimestampSlopePerSecond(), 1e-12); + assertEquals(1.0, stats.getTimestampIntercept(), 1e-12); + + JsonObject json = stats.toJson(); + assertEquals(2.0, json.get("timestampSlopePerSec").getAsDouble(), 1e-12); + assertEquals(1.0, json.get("timestampIntercept").getAsDouble(), 1e-12); + } + + + @Test + void timestampRegression_matchesPerfectLine() { + TrendStatistics stats = new TrendStatistics(); + + // y = 2*t + 1, with t in seconds; timestamps are nanos + stats.update(1_000_000_000L, 3.0); // t=1 -> y=3 + stats.update(2_000_000_000L, 5.0); // t=2 -> y=5 + stats.update(3_000_000_000L, 7.0); // t=3 -> y=7 + + assertEquals(2.0, stats.getTimestampSlopePerSecond(), 1e-12); + assertEquals(1.0, stats.getTimestampIntercept(), 1e-12); + + JsonObject json = stats.toJson(); + assertEquals(2.0, json.get("timestampSlopePerSec").getAsDouble(), 1e-12); + assertEquals(1.0, json.get("timestampIntercept").getAsDouble(), 1e-12); + } + + @Test + void timestampRegression_isZeroWhenDenominatorZero() { + TrendStatistics stats = new TrendStatistics(); + + // same timestamp => denominator becomes 0 + stats.update(1_000_000_000L, 10.0); + stats.update(1_000_000_000L, 20.0); + + assertEquals(0.0, stats.getTimestampSlopePerSecond(), 0.0); + assertEquals(0.0, stats.getTimestampIntercept(), 0.0); + } + + @Test + void reset_clearsTimestampState() { + TrendStatistics stats = new TrendStatistics(); + stats.update(1_000_000_000L, 3.0); + stats.update(2_000_000_000L, 5.0); + + stats.reset(); + + assertEquals(0.0, stats.getTimestampSlopePerSecond(), 0.0); + assertEquals(0.0, stats.getTimestampIntercept(), 0.0); + + JsonObject json = stats.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0.0, json.get("timestampSlopePerSec").getAsDouble(), 0.0); + assertEquals(0.0, json.get("timestampIntercept").getAsDouble(), 0.0); + } + + @Test + void create_returnsNewInstanceWithCleanState() { + TrendStatistics stats = new TrendStatistics(); + stats.update(1_000_000_000L, 3.0); + + Statistics created = stats.create(); + assertInstanceOf(TrendStatistics.class, created); + + JsonObject json = created.toJson(); + assertEquals(0, json.get("count").getAsInt()); + assertEquals(0.0, json.get("timestampSlopePerSec").getAsDouble(), 0.0); + } +} diff --git a/src/test/java/io/mapsmessaging/analytics/impl/stats/WindowedStatisticsTest.java b/src/test/java/io/mapsmessaging/analytics/impl/stats/WindowedStatisticsTest.java new file mode 100644 index 000000000..d02ca04e0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/analytics/impl/stats/WindowedStatisticsTest.java @@ -0,0 +1,112 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.analytics.impl.stats; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class WindowedStatisticsTest { + + @Test + void window_tracksOnlyLastNValues() { + WindowedStatistics stats = new WindowedStatistics(3); + + stats.update(1.0); + stats.update(2.0); + stats.update(3.0); + assertEquals(3, stats.getWindowCount()); + assertEquals(2.0, stats.getWindowMean(), 1e-12); + + stats.update(4.0); // window now [2,3,4] + assertEquals(3, stats.getWindowCount()); + assertEquals(3.0, stats.getWindowMean(), 1e-12); + } + + @Test + void windowStdDev_matchesSampleStdDevOfWindow() { + WindowedStatistics stats = new WindowedStatistics(3); + + // window [2,3,4] => mean=3, sample variance = ((4+9+16) - 9*3)/2 = (29-27)/2 = 1 + stats.update(1.0); + stats.update(2.0); + stats.update(3.0); + stats.update(4.0); + + assertEquals(1.0, stats.getWindowStdDeviation(), 1e-12); + } + + @Test + void windowStdDev_isZeroWhenFewerThanTwoSamples() { + WindowedStatistics stats = new WindowedStatistics(5); + stats.update(10.0); + + assertEquals(1, stats.getWindowCount()); + assertEquals(0.0, stats.getWindowStdDeviation(), 0.0); + } + + @Test + void toJson_containsWindowFields() { + WindowedStatistics stats = new WindowedStatistics(3); + stats.update(1.0); + stats.update(2.0); + stats.update(3.0); + + JsonObject json = stats.toJson(); + assertEquals(3, json.get("windowSize").getAsInt()); + assertEquals(3, json.get("windowCount").getAsInt()); + assertEquals(2.0, json.get("windowMean").getAsDouble(), 1e-12); + assertTrue(json.has("windowStdDev")); + } + + @Test + void reset_clearsWindowAndBaseStats() { + WindowedStatistics stats = new WindowedStatistics(3); + stats.update(1.0); + stats.update(2.0); + + stats.reset(); + + assertEquals(0, stats.getWindowCount()); + assertEquals(0.0, stats.getWindowMean(), 0.0); + assertEquals(0.0, stats.getWindowStdDeviation(), 0.0); + + JsonObject json = stats.toJson(); + assertEquals(0, json.get("count").getAsInt()); + } + + @Test + void create_returnsNewInstanceSameWindowSize_cleanState() { + WindowedStatistics stats = new WindowedStatistics(7); + stats.update(1.0); + + Statistics created = stats.create(); + assertInstanceOf(WindowedStatistics.class, created); + + WindowedStatistics ws = (WindowedStatistics) created; + assertEquals(0, ws.getWindowCount()); + assertEquals(0.0, ws.getWindowMean(), 0.0); + + JsonObject json = ws.toJson(); + assertEquals(7, json.get("windowSize").getAsInt()); + assertEquals(0, json.get("windowCount").getAsInt()); + } +} diff --git a/src/test/java/io/mapsmessaging/api/MessageAPITest.java b/src/test/java/io/mapsmessaging/api/MessageAPITest.java index ad5e7a215..32dccaa02 100644 --- a/src/test/java/io/mapsmessaging/api/MessageAPITest.java +++ b/src/test/java/io/mapsmessaging/api/MessageAPITest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -34,10 +34,9 @@ public class MessageAPITest extends BaseTestConfig { protected EngineManager engineManager; - protected SecurityManager previous; @BeforeEach - public void setupTest() throws IOException { + void setupTest() throws IOException { if(!BaseTestConfig.md.isStarted()){ BaseTestConfig.md.start(); } @@ -46,36 +45,10 @@ public void setupTest() throws IOException { } @AfterEach - public void resetState() { + void resetState() { engineManager.reset(); } - public Session createSession(String name, int keepAlive, int expiry, boolean persistent, MessageListener listener) throws LoginException, IOException { - return createSession(name, keepAlive, expiry, persistent, listener, false); - } - - public Session createSession(String name, int keepAlive, int expiry, boolean persistent, MessageListener listener, boolean resetState) throws LoginException, IOException { - Protocol fakeProtocol = new FakeProtocol(listener); - SessionContextBuilder scb = new SessionContextBuilder(name, new ProtocolClientConnection(fakeProtocol)); - scb.setPersistentSession(true) - .setPersistentSession(persistent) - .setResetState(resetState) - .setSessionExpiry(expiry); - return createSession(scb, fakeProtocol); - } - - public Session createSession(SessionContextBuilder scb, MessageListener listener) throws LoginException, IOException { - Session session = SessionManager.getInstance().create(scb.build(), listener); - session.login(); - session.resumeState(); - //Assertions.assertTrue(session.isRestored()); - return session; - } - - public void close(Session session) throws IOException { - SessionManager.getInstance().close(session, false); - } - public boolean hasSessions(){ return engineManager.hasSessions(); } diff --git a/src/test/java/io/mapsmessaging/api/MessageBuilderTest.java b/src/test/java/io/mapsmessaging/api/MessageBuilderTest.java new file mode 100644 index 000000000..0e875ac1e --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/MessageBuilderTest.java @@ -0,0 +1,71 @@ +package io.mapsmessaging.api; + +import io.mapsmessaging.api.message.TypedData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +class MessageBuilderTest { + + @Test + void setMeta_mergesWhenBothNonNull() { + MessageBuilder builder = new MessageBuilder(); + + Map first = new LinkedHashMap<>(); + first.put("a", "1"); + + Map second = new LinkedHashMap<>(); + second.put("b", "2"); + + builder.setMeta(first); + builder.setMeta(second); + + Assertions.assertNotNull(builder.getMeta()); + Assertions.assertEquals("1", builder.getMeta().get("a")); + Assertions.assertEquals("2", builder.getMeta().get("b")); + } + + @Test + void setDataMap_mergesWhenBothNonNull() { + MessageBuilder builder = new MessageBuilder(); + + Map first = new LinkedHashMap<>(); + first.put("a", new TypedData("x")); + + Map second = new LinkedHashMap<>(); + second.put("b", new TypedData(123L)); + + builder.setDataMap(first); + builder.setDataMap(second); + + Assertions.assertNotNull(builder.getDataMap()); + Assertions.assertEquals("x", builder.getDataMap().get("a").getData()); + Assertions.assertEquals(123L, builder.getDataMap().get("b").getData()); + } + + @Test + void setMeta_replacesWhenCurrentNull() { + MessageBuilder builder = new MessageBuilder(); + + Map meta = new LinkedHashMap<>(); + meta.put("k", "v"); + + builder.setMeta(meta); + + Assertions.assertEquals("v", builder.getMeta().get("k")); + } + + @Test + void setDataMap_replacesWhenCurrentNull() { + MessageBuilder builder = new MessageBuilder(); + + Map data = new LinkedHashMap<>(); + data.put("k", new TypedData("v")); + + builder.setDataMap(data); + + Assertions.assertEquals("v", builder.getDataMap().get("k").getData()); + } +} diff --git a/src/test/java/io/mapsmessaging/api/SessionTest.java b/src/test/java/io/mapsmessaging/api/SessionTest.java index fd8ae07fa..0ec395b33 100644 --- a/src/test/java/io/mapsmessaging/api/SessionTest.java +++ b/src/test/java/io/mapsmessaging/api/SessionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -40,7 +40,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; -public class SessionTest extends MessageAPITest implements ProtocolMessageListener { +class SessionTest extends MessageAPITest implements ProtocolMessageListener { private final AtomicInteger receivedKeepAlive = new AtomicInteger(0); @@ -49,7 +49,8 @@ public class SessionTest extends MessageAPITest implements ProtocolMessageListen @Test @DisplayName("Simple session construction tests") - public void sessionConstructionTest(TestInfo testInfo) throws Exception { + void sessionConstructionTest(TestInfo testInfo) throws Exception { + int initialCount = SessionManagerTest.getInstance().sessionCount(); Session session = createSession(testInfo.getTestMethod().get().getName(), 5, 2, false, this); Assertions.assertTrue(SessionManagerTest.getInstance().hasSessions()); Destination destinationImpl = session.findDestination("topic1", DestinationType.TOPIC).get(); @@ -73,7 +74,7 @@ public void sessionConstructionTest(TestInfo testInfo) throws Exception { Assertions.assertFalse(messageList.isEmpty()); close(session); - Assertions.assertFalse(hasSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); TimeUnit.SECONDS.sleep(4); if(SessionManagerTest.getInstance().hasIdleSessions()){ for(String sessionName:SessionManagerTest.getInstance().getIdleSessions()){ @@ -82,7 +83,7 @@ public void sessionConstructionTest(TestInfo testInfo) throws Exception { super.closeSubscriptionController(controller); } } - Assertions.assertFalse(SessionManagerTest.getInstance().hasIdleSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); } @Test @@ -101,6 +102,8 @@ void sessionCloseOnDuplicateTest(TestInfo testInfo) throws Exception { @Test @DisplayName("Simple session expiry tests") void sessionExpiryTest(TestInfo testInfo) throws Exception { + int initialCount = SessionManagerTest.getInstance().sessionCount(); + Session session = createSession(testInfo.getTestMethod().get().getName(), 5, 2, true, this, true); close(session); session = createSession(testInfo.getTestMethod().get().getName(), 5, 2, true, this); @@ -111,13 +114,14 @@ void sessionExpiryTest(TestInfo testInfo) throws Exception { Assertions.assertNotNull(subscription); close(session); - Assertions.assertFalse(hasSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); + int size = SessionManagerTest.getInstance().getIdleSessions().size(); Assertions.assertTrue(size>0); SubscriptionController subscriptionController = SessionManagerTest.getInstance().getIdleSubscriptions(testInfo.getTestMethod().get().getName()); Assertions.assertNotNull(subscriptionController); Map map = subscriptionController.getSubscriptions(); - Assertions.assertEquals(map.size(), 1); + Assertions.assertEquals(1, map.size()); SubscriptionContext context = map.get("$normaltopic1"); Assertions.assertNotNull(context); TimeUnit.SECONDS.sleep(3); @@ -127,23 +131,26 @@ void sessionExpiryTest(TestInfo testInfo) throws Exception { @Test @DisplayName("Simple session keep alive state") void sessionKeepAlive(TestInfo testInfo) throws Exception { + int initialCount = SessionManagerTest.getInstance().sessionCount(); receivedKeepAlive.set(0); Session session = createSession(testInfo.getTestMethod().get().getName(), 5, 12, true, this); - Assertions.assertTrue(hasSessions()); + Assertions.assertEquals(initialCount+1, SessionManagerTest.getInstance().sessionCount()); WaitForState.waitFor(10, TimeUnit.SECONDS, () -> receivedKeepAlive.get() != 0); Assertions.assertTrue(receivedKeepAlive.get() != 0); receivedKeepAlive.set(0); close(session); - Assertions.assertFalse(hasSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); Assertions.assertTrue(SessionManagerTest.getInstance().hasIdleSessions()); WaitForState.waitFor(12, TimeUnit.SECONDS, () -> receivedKeepAlive.get() != 0); Assertions.assertEquals(receivedKeepAlive.get(), 0); - Assertions.assertFalse(SessionManagerTest.getInstance().hasIdleSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); } @Test @DisplayName("Simple session subscriptions") - public void idleSessionSubscriptions(TestInfo testInfo) throws Exception { + void idleSessionSubscriptions(TestInfo testInfo) throws Exception { + int initialCount = SessionManagerTest.getInstance().sessionCount(); + receivedKeepAlive.set(0); Session session = createSession(testInfo.getTestMethod().get().getName(), 5, 10000, true, this); session.resumeState(); @@ -199,7 +206,7 @@ public void idleSessionSubscriptions(TestInfo testInfo) throws Exception { Assertions.assertFalse(messageList.isEmpty()); close(session); - Assertions.assertFalse(hasSessions()); + Assertions.assertEquals(initialCount, SessionManagerTest.getInstance().sessionCount()); Assertions.assertTrue(SessionManagerTest.getInstance().hasIdleSessions()); count = 0; while(SessionManagerTest.getInstance().hasIdleSessions() && count < 110){ diff --git a/src/test/java/io/mapsmessaging/api/message/MessageFactoryTest.java b/src/test/java/io/mapsmessaging/api/message/MessageFactoryTest.java index db2ee9f92..ed1c7e787 100644 --- a/src/test/java/io/mapsmessaging/api/message/MessageFactoryTest.java +++ b/src/test/java/io/mapsmessaging/api/message/MessageFactoryTest.java @@ -1,3 +1,22 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.message; import io.mapsmessaging.api.MessageBuilder; @@ -29,8 +48,8 @@ private Message createBaseMessage() { messageBuilder.setOpaqueData(opaquePayload); messageBuilder.setPriority(Priority.HIGHEST); - messageBuilder.setQualityOfService(QualityOfService.AT_LEAST_ONCE); - messageBuilder.setStoreOffline(true); + messageBuilder.setQoS(QualityOfService.AT_LEAST_ONCE); + messageBuilder.storeOffline(true); messageBuilder.setCorrelationData("corr-id-123"); messageBuilder.setContentType("text/plain"); @@ -172,21 +191,21 @@ void packAndUnpackWithByteArrayCorrelationData() throws Exception { messageBuilder.setOpaqueData(opaquePayload); messageBuilder.setPriority(Priority.LOWEST); - messageBuilder.setQualityOfService(QualityOfService.AT_MOST_ONCE); + messageBuilder.setQoS(QualityOfService.AT_MOST_ONCE); - messageBuilder.setStoreOffline(false); + messageBuilder.storeOffline(false); byte[] correlationBytes = "corr-binary".getBytes(StandardCharsets.UTF_8); messageBuilder.setCorrelationData(correlationBytes); - messageBuilder.setContentType("application/octet-stream"); - messageBuilder.setDelayed(0L); - messageBuilder.setExpiry(0L); - messageBuilder.setCreation(1728000000001L); - messageBuilder.setResponseTopic("binary/reply"); - messageBuilder.setRetain(false); - messageBuilder.setPayloadUTF8(false); - messageBuilder.setSchemaId(null); + messageBuilder.setContentType("application/octet-stream") + .setDelayed(0L) + .setExpiry(0L) + .setCreation(1728000000001L) + .setResponseTopic("binary/reply") + .setRetain(false) + .setPayloadUTF8(false) + .setSchemaId(null); Message originalMessage = new Message(messageBuilder); @@ -219,8 +238,8 @@ void opaqueDataAbsentIsHandled() throws Exception { messageBuilder.setOpaqueData(null); messageBuilder.setPriority(Priority.NORMAL); - messageBuilder.setQualityOfService(QualityOfService.EXACTLY_ONCE); - messageBuilder.setStoreOffline(true); + messageBuilder.setQoS(QualityOfService.EXACTLY_ONCE); + messageBuilder.storeOffline(true); messageBuilder.setCorrelationData("no-payload"); messageBuilder.setContentType("text/plain"); messageBuilder.setDelayed(0L); @@ -251,8 +270,8 @@ void nullMetaStaysNullAfterRoundTrip() throws Exception { messageBuilder.setMeta(null); messageBuilder.setOpaqueData("x".getBytes(StandardCharsets.UTF_8)); messageBuilder.setPriority(Priority.NORMAL); - messageBuilder.setQualityOfService(QualityOfService.AT_MOST_ONCE); - messageBuilder.setStoreOffline(true); + messageBuilder.setQoS(QualityOfService.AT_MOST_ONCE); + messageBuilder.storeOffline(true); messageBuilder.setCorrelationData("c"); messageBuilder.setContentType("text/plain"); messageBuilder.setDelayed(0L); @@ -280,8 +299,8 @@ void emptyMetaRoundTripsAsEmptyMap() throws Exception { messageBuilder.setMeta(new LinkedHashMap<>()); // empty but non-null messageBuilder.setOpaqueData("y".getBytes(StandardCharsets.UTF_8)); messageBuilder.setPriority(Priority.NORMAL); - messageBuilder.setQualityOfService(QualityOfService.AT_MOST_ONCE); - messageBuilder.setStoreOffline(true); + messageBuilder.setQoS(QualityOfService.AT_MOST_ONCE); + messageBuilder.storeOffline(true); messageBuilder.setCorrelationData("c2"); messageBuilder.setContentType("text/plain"); messageBuilder.setDelayed(0L); @@ -299,7 +318,8 @@ void emptyMetaRoundTripsAsEmptyMap() throws Exception { Message unpacked = factory.unpack(duplicateForRead(packed)); assertNotNull(unpacked.getMeta()); - assertTrue(unpacked.getMeta().size() == 1); // includes time_ms created + + assertTrue(unpacked.getMeta().size() == 1 || unpacked.getMeta().isEmpty(), "This should be 1 or 0 not "+unpacked.getMeta().size()); // includes time_ms created and / or location } @Test @@ -312,8 +332,8 @@ void storeOfflineIsForcedTrueAfterReload() throws Exception { messageBuilder.setMeta(map); messageBuilder.setOpaqueData("z".getBytes(StandardCharsets.UTF_8)); messageBuilder.setPriority(Priority.NORMAL); - messageBuilder.setQualityOfService(QualityOfService.AT_MOST_ONCE); - messageBuilder.setStoreOffline(false); // explicitly false + messageBuilder.setQoS(QualityOfService.AT_MOST_ONCE); + messageBuilder.storeOffline(false); // explicitly false messageBuilder.setCorrelationData("c3"); messageBuilder.setContentType("text/plain"); messageBuilder.setDelayed(0L); diff --git a/src/test/java/io/mapsmessaging/api/message/MessagePackRoundTripTest.java b/src/test/java/io/mapsmessaging/api/message/MessagePackRoundTripTest.java new file mode 100644 index 000000000..4c7f32f2c --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/message/MessagePackRoundTripTest.java @@ -0,0 +1,78 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.message; + +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.features.Priority; +import io.mapsmessaging.api.features.QualityOfService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.Map; + +class MessagePackRoundTripTest { + + @Test + void packAndUnpack_preservesCoreFields() throws IOException { + Map meta = new LinkedHashMap<>(); + meta.put("m1", "v1"); + meta.put("m2", "v2"); + + Map data = new LinkedHashMap<>(); + data.put("d1", new TypedData("x")); + data.put("d2", new TypedData(99L)); + + Message original = new MessageBuilder() + .setCorrelationData("corr") + .setOpaqueData(new byte[]{9, 8, 7}) + .setContentType("application/test") + .setResponseTopic("reply/topic") + .setPriority(Priority.ONE_BELOW_HIGHEST) + .setQoS(QualityOfService.EXACTLY_ONCE) + .setMeta(meta) + .setDataMap(data) + .setPayloadIndicator(true) + .setRetain(true) + .setSchemaId("schema-9") + .build(); + + ByteBuffer[] packed = original.pack(); + Message unpacked = new Message(packed); + + Assertions.assertEquals(original.getPriority(), unpacked.getPriority()); + Assertions.assertEquals(original.getQualityOfService(), unpacked.getQualityOfService()); + Assertions.assertEquals(original.getResponseTopic(), unpacked.getResponseTopic()); + Assertions.assertEquals(original.getContentType(), unpacked.getContentType()); + Assertions.assertEquals(original.isRetain(), unpacked.isRetain()); + Assertions.assertEquals(original.isUTF8(), unpacked.isUTF8()); + Assertions.assertEquals(original.getSchemaId(), unpacked.getSchemaId()); + + Assertions.assertArrayEquals(original.getOpaqueData(), unpacked.getOpaqueData()); + + // meta in Message returns a copy if null; here it should exist + Assertions.assertEquals("v1", unpacked.getMeta().get("m1")); + Assertions.assertEquals("v2", unpacked.getMeta().get("m2")); + + Assertions.assertEquals("x", unpacked.getDataMap().get("d1").getData()); + Assertions.assertEquals(99L, unpacked.getDataMap().get("d2").getData()); + } +} diff --git a/src/test/java/io/mapsmessaging/api/overrides/BaseOverridesTest.java b/src/test/java/io/mapsmessaging/api/overrides/BaseOverridesTest.java index 6fc6eeb0e..ca11e9b0f 100644 --- a/src/test/java/io/mapsmessaging/api/overrides/BaseOverridesTest.java +++ b/src/test/java/io/mapsmessaging/api/overrides/BaseOverridesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/queue/QueueTest.java b/src/test/java/io/mapsmessaging/api/queue/QueueTest.java index 0c2dbc624..717bd8803 100644 --- a/src/test/java/io/mapsmessaging/api/queue/QueueTest.java +++ b/src/test/java/io/mapsmessaging/api/queue/QueueTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/subscriptions/DelayedPublishTest.java b/src/test/java/io/mapsmessaging/api/subscriptions/DelayedPublishTest.java index 9f880d7fa..a137d23c3 100644 --- a/src/test/java/io/mapsmessaging/api/subscriptions/DelayedPublishTest.java +++ b/src/test/java/io/mapsmessaging/api/subscriptions/DelayedPublishTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/subscriptions/FilteredQueueSubscriptionTest.java b/src/test/java/io/mapsmessaging/api/subscriptions/FilteredQueueSubscriptionTest.java index f5fba8649..41787c260 100644 --- a/src/test/java/io/mapsmessaging/api/subscriptions/FilteredQueueSubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/api/subscriptions/FilteredQueueSubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/subscriptions/SelectorTest.java b/src/test/java/io/mapsmessaging/api/subscriptions/SelectorTest.java index d8a15eea3..fdca9371f 100644 --- a/src/test/java/io/mapsmessaging/api/subscriptions/SelectorTest.java +++ b/src/test/java/io/mapsmessaging/api/subscriptions/SelectorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/subscriptions/UnsubscribeValidationTest.java b/src/test/java/io/mapsmessaging/api/subscriptions/UnsubscribeValidationTest.java index d115b8346..dc4436df9 100644 --- a/src/test/java/io/mapsmessaging/api/subscriptions/UnsubscribeValidationTest.java +++ b/src/test/java/io/mapsmessaging/api/subscriptions/UnsubscribeValidationTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/transactions/SimpleTransactionalTest.java b/src/test/java/io/mapsmessaging/api/transactions/SimpleTransactionalTest.java index 7548ed347..c50991364 100644 --- a/src/test/java/io/mapsmessaging/api/transactions/SimpleTransactionalTest.java +++ b/src/test/java/io/mapsmessaging/api/transactions/SimpleTransactionalTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/api/transformers/AbstractCloudEventTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/AbstractCloudEventTransformationTest.java new file mode 100644 index 000000000..4e7df785f --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/AbstractCloudEventTransformationTest.java @@ -0,0 +1,125 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO; +import io.mapsmessaging.engine.transformers.TransformerManager; + +import java.nio.charset.StandardCharsets; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertNotDropped; +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertOpaqueDataChanged; +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertOpaqueDataIsJson; +import static org.junit.jupiter.api.Assertions.*; + +abstract class AbstractCloudEventTransformationTest extends AbstractInPlaceTransformationTest { + + protected static final String MAPS_DATA_KEY = "_mapsData"; + protected static final String PAYLOAD_KEY = "_payload"; + protected static final String PAYLOAD_BASE64_KEY = "payload_base64"; + protected static final String PAYLOAD_MIME_KEY = "payload_mime"; + + protected abstract TransformationConfigDTO getConfig(); + + protected InterServerTransformation createTransformer() { + return TransformerManager.getInstance().get(getConfig()); + } + + protected final ParsedMessage transformExpectingCloudEvent(byte[] before) { + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataChanged(before, result); + assertOpaqueDataIsJson(result); + + return result; + } + + protected final JsonObject parseCloudEventObject(ParsedMessage result) { + byte[] bytes = result.getMessage().getOpaqueData(); + assertNotNull(bytes); + + String json = new String(bytes, StandardCharsets.UTF_8); + JsonElement parsed = JsonParser.parseString(json); + + assertNotNull(parsed); + assertTrue(parsed.isJsonObject(), "Expected CloudEvent JSON object but got: " + parsed); + + return parsed.getAsJsonObject(); + } + + protected final void assertRequiredCloudEventAttributes(JsonObject ce) { + assertHasNonBlankString(ce, "specversion"); + assertHasNonBlankString(ce, "id"); + assertHasNonBlankString(ce, "source"); + assertHasNonBlankString(ce, "type"); + } + + protected final JsonObject assertHasDataObject(JsonObject ce) { + assertTrue(ce.has("data"), "Expected top-level 'data' field"); + JsonElement data = ce.get("data"); + assertNotNull(data); + assertFalse(data.isJsonNull(), "data is null"); + assertTrue(data.isJsonObject(), "Expected data to be a JSON object but got: " + data); + return data.getAsJsonObject(); + } + + protected final void assertHasPayloadWrappedValue(JsonObject data) { + assertTrue(data.has(PAYLOAD_KEY), "Expected data." + PAYLOAD_KEY); + JsonElement payload = data.get(PAYLOAD_KEY); + assertNotNull(payload); + assertFalse(payload.isJsonNull(), "data." + PAYLOAD_KEY + " is null"); + } + + protected final void assertHasPayloadBase64Wrapper(JsonObject data) { + assertTrue(data.has(PAYLOAD_BASE64_KEY), "Expected data." + PAYLOAD_BASE64_KEY); + assertTrue(data.has(PAYLOAD_MIME_KEY), "Expected data." + PAYLOAD_MIME_KEY); + + String base64 = data.get(PAYLOAD_BASE64_KEY).getAsString(); + String mime = data.get(PAYLOAD_MIME_KEY).getAsString(); + + assertNotNull(base64); + assertFalse(base64.isBlank()); + + assertNotNull(mime); + assertFalse(mime.isBlank()); + } + + protected final void assertMapsDataIfPresentIsObject(JsonObject data) { + if (!data.has(MAPS_DATA_KEY) || data.get(MAPS_DATA_KEY).isJsonNull()) { + return; + } + assertTrue(data.get(MAPS_DATA_KEY).isJsonObject(), "data." + MAPS_DATA_KEY + " must be an object"); + } + + protected final void assertHasNonBlankString(JsonObject obj, String key) { + assertTrue(obj.has(key), "Missing '" + key + "'"); + JsonElement element = obj.get(key); + assertNotNull(element); + assertFalse(element.isJsonNull(), key + " is null"); + assertTrue(element.isJsonPrimitive(), key + " must be a primitive"); + String value = element.getAsString(); + assertNotNull(value); + assertFalse(value.isBlank(), key + " is blank"); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/AbstractDroppingTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/AbstractDroppingTransformationTest.java new file mode 100644 index 000000000..4f6deedef --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/AbstractDroppingTransformationTest.java @@ -0,0 +1,51 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.network.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertNotDropped; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public abstract class AbstractDroppingTransformationTest extends AbstractInterServerTransformationTest { + + protected abstract byte[] validInputBytes(); + + @Test + void transform_validInput_doesNotThrow() { + ParsedMessage result = transform(validInputBytes()); + // drop is allowed, but valid inputs should generally not drop unless the transformer is a filter + // If a concrete transformer is truly a filter, it can override and assertDropped instead. + assertNotNull(result, "Valid input unexpectedly dropped; override test if this is intentional filter behavior"); + } + + @Test + void transform_invalidInput_doesNotThrow() { + ParsedMessage result = transform(utf8Bytes(TransformationTestVectors.NON_JSON_TEXT)); + // drop or pass-through is allowed; crash is not. + // No assertion here except "it didn't throw". + // If you want consistent behaviour, override in the specific transformer test. + if (result != null) { + assertNotDropped(result); + } + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/AbstractInPlaceTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/AbstractInPlaceTransformationTest.java new file mode 100644 index 000000000..2de214efc --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/AbstractInPlaceTransformationTest.java @@ -0,0 +1,45 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.network.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertNotDropped; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; + +public abstract class AbstractInPlaceTransformationTest extends AbstractInterServerTransformationTest { + + protected abstract byte[] validInputBytes(); + + @Test + void transform_validInput_doesNotDrop() { + ParsedMessage result = transform(validInputBytes()); + assertNotDropped(result); + } + + @Test + void transform_invalidInput_doesNotThrow() { + ParsedMessage result = transform(utf8Bytes(TransformationTestVectors.NON_JSON_TEXT)); + // may or may not change payload, but must not crash + // For in-place transformers, we expect not dropped. + assertNotDropped(result); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/AbstractInterServerTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/AbstractInterServerTransformationTest.java new file mode 100644 index 000000000..fcbbe7dbe --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/AbstractInterServerTransformationTest.java @@ -0,0 +1,72 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonObject; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.schema.SchemaManager; +import io.mapsmessaging.network.protocol.Protocol; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.mapsmessaging.schemas.config.impl.JsonSchemaConfig; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.BeforeEach; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.mockMessage; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.parsedMessage; +import static io.mapsmessaging.engine.schema.SchemaManager.DEFAULT_JSON_SCHEMA; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public abstract class AbstractInterServerTransformationTest extends BaseTestConfig { + + public static final SchemaConfig JSON_SCHEMA_CONFIG = buildSchema(); + protected static final String SOURCE = "/test/source"; + protected static final String DESTINATION = "/test/destination"; + protected InterServerTransformation transformer; + + private static SchemaConfig buildSchema() { + return SchemaManager.getInstance().getSchema(DEFAULT_JSON_SCHEMA); + } + + protected abstract InterServerTransformation createTransformer(); + + @BeforeEach + void setupTransformer() { + transformer = createTransformer(); + assertNotNull(transformer, "createTransformer() must not return null"); + } + + protected ParsedMessage transform(byte[] opaqueData) { + Message message = mockMessage(opaqueData, DEFAULT_JSON_SCHEMA.toString()); + ParsedMessage parsedMessage = parsedMessage(DESTINATION, message); + return assertDoesNotThrow(() -> transformer.transform(SOURCE, parsedMessage)); + } + + protected ParsedMessage transform(byte[] opaqueData, String schemaId) { + Message message = mockMessage(opaqueData, schemaId); + message.setSchemaId(DEFAULT_JSON_SCHEMA.toString()); + ParsedMessage parsedMessage = parsedMessage(DESTINATION, message); + return assertDoesNotThrow(() -> transformer.transform(SOURCE, parsedMessage)); + } + + protected ParsedMessage transformWithParsed(ParsedMessage parsedMessage) { + return assertDoesNotThrow(() -> transformer.transform(SOURCE, parsedMessage)); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/AvroSchemaToJsonTest.java b/src/test/java/io/mapsmessaging/api/transformers/AvroSchemaToJsonTest.java new file mode 100644 index 000000000..51bf501d1 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/AvroSchemaToJsonTest.java @@ -0,0 +1,117 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.schema.SchemaManager; +import io.mapsmessaging.network.protocol.transformation.SchemaToJsonTransformation; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static io.mapsmessaging.api.transformers.TestAvroSchemas.SCHEMA_JSON; +import static io.mapsmessaging.api.transformers.TestAvroSchemas.avroSchemaConfig; +import static org.junit.jupiter.api.Assertions.*; + +class AvroSchemaToJsonTest extends BaseTestConfig { + + private SchemaManager schemaManager; + + private UUID schemaId; + + @BeforeEach + void setUp() { + schemaManager = SchemaManager.getInstance(); + } + + @AfterEach + void tearDown() { + schemaManager.removeSchema(schemaId.toString()); + } + + @Test + void avro_binaryPayload_isConvertedToJson() { + schemaId = UUID.randomUUID(); + + SchemaConfig schemaConfig = avroSchemaConfig(schemaId, SCHEMA_JSON); + schemaManager.addSchema("/avro", schemaConfig); + + byte[] payload = TestAvroSchemas.encodeAvroPayload(); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), payload); + Message message = builder.build(); + message = transform.outgoing(message, "/avro"); + byte[] outPayload = message.getOpaqueData(); + JsonObject json = JsonParser.parseString(new String(outPayload, StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject payloadJson = json.getAsJsonObject("payload"); + + assertEquals("abc", payloadJson.get("id").getAsString()); + assertEquals(123, payloadJson.get("count").getAsInt()); + assertTrue(payloadJson.get("active").getAsBoolean()); + } + + @Test + void avro_missingSchema_dropsMessage() { + schemaId = UUID.randomUUID(); + + byte[] payload = TestAvroSchemas.encodeAvroPayload(); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), payload); + Message message = builder.build(); + + Message out = transform.outgoing(message, "/avro"); // not registered + + assertNull(out); + } + + @Test + void avro_truncatedPayload_dropsMessage() { + schemaId = UUID.randomUUID(); + + SchemaConfig schemaConfig = avroSchemaConfig(schemaId, SCHEMA_JSON); + schemaManager.addSchema("/avro", schemaConfig); + + byte[] payload = TestAvroSchemas.encodeAvroPayload(); + byte[] truncated = new byte[Math.max(0, payload.length - 1)]; + System.arraycopy(payload, 0, truncated, 0, truncated.length); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), truncated); + Message message = builder.build(); + + Message out = transform.outgoing(message, "/avro"); + + assertNull(out); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/CloudEventEnvelopeTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/CloudEventEnvelopeTransformationTest.java new file mode 100644 index 000000000..9e3b4597a --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/CloudEventEnvelopeTransformationTest.java @@ -0,0 +1,62 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonObject; +import io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO; +import io.mapsmessaging.dto.rest.config.transformer.impl.CloudEventEnvelopeTransformationDTO; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.*; + +class CloudEventEnvelopeTransformationTest extends AbstractCloudEventTransformationTest { + + @Override + protected TransformationConfigDTO getConfig() { + return new CloudEventEnvelopeTransformationDTO(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + } + + @Test + void transform_validPayload_producesCloudEventEnvelopeWithPayloadRepresentation() { + byte[] before = validInputBytes(); + + ParsedMessage result = transformExpectingCloudEvent(before); + JsonObject ce = parseCloudEventObject(result); + + assertRequiredCloudEventAttributes(ce); + + JsonObject data = assertHasDataObject(ce); + assertMapsDataIfPresentIsObject(data); + + boolean hasWrappedPayload = data.has(PAYLOAD_KEY) && !data.get(PAYLOAD_KEY).isJsonNull(); + boolean hasBase64Payload = data.has(PAYLOAD_BASE64_KEY) && !data.get(PAYLOAD_BASE64_KEY).isJsonNull(); + + assertTrue( + hasWrappedPayload || hasBase64Payload, + "Expected payload representation in data." + PAYLOAD_KEY + " or data." + PAYLOAD_BASE64_KEY + ); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/CloudEventJsonTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/CloudEventJsonTransformationTest.java new file mode 100644 index 000000000..6884a359f --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/CloudEventJsonTransformationTest.java @@ -0,0 +1,87 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO; +import io.mapsmessaging.dto.rest.config.transformer.impl.CloudEventJsonTransformationDTO; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CloudEventJsonTransformationTest extends AbstractCloudEventTransformationTest { + + @Override + protected TransformationConfigDTO getConfig() { + return new CloudEventJsonTransformationDTO(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + } + + @Test + void transform_validJsonObject_producesCloudEventWithDataObject() { + byte[] before = validInputBytes(); + + ParsedMessage result = transformExpectingCloudEvent(before); + JsonObject ce = parseCloudEventObject(result); + + assertRequiredCloudEventAttributes(ce); + + JsonObject data = assertHasDataObject(ce); + assertMapsDataIfPresentIsObject(data); + } + + @Test + void transform_jsonArray_wrapsUnderPayloadKey() { + byte[] before = utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + + ParsedMessage result = transformExpectingCloudEvent(before); + JsonObject ce = parseCloudEventObject(result); + + assertRequiredCloudEventAttributes(ce); + + JsonObject data = assertHasDataObject(ce); + JsonObject expected = JsonParser.parseString(TransformationTestVectors.VALID_JSON_OBJECT).getAsJsonObject(); + + assertEquals(expected, data); + + + assertMapsDataIfPresentIsObject(data); + } + + @Test + void transform_invalidJson_fallsBackToPayloadBase64Wrapper() { + byte[] before = utf8Bytes(TransformationTestVectors.INVALID_JSON); + + ParsedMessage result = transformExpectingCloudEvent(before); + JsonObject ce = parseCloudEventObject(result); + + assertRequiredCloudEventAttributes(ce); + + JsonObject data = assertHasDataObject(ce); + assertHasPayloadBase64Wrapper(data); + assertMapsDataIfPresentIsObject(data); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/CloudEventNativeTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/CloudEventNativeTransformationTest.java new file mode 100644 index 000000000..80e9046fd --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/CloudEventNativeTransformationTest.java @@ -0,0 +1,38 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO; +import io.mapsmessaging.dto.rest.config.transformer.impl.CloudEventNativeTransformationDTO; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; + +class CloudEventNativeTransformationTest extends AbstractCloudEventTransformationTest { + + @Override + protected TransformationConfigDTO getConfig() { + return new CloudEventNativeTransformationDTO(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/GeoHashResolverTest.java b/src/test/java/io/mapsmessaging/api/transformers/GeoHashResolverTest.java new file mode 100644 index 000000000..33f0c52fa --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/GeoHashResolverTest.java @@ -0,0 +1,322 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.api.message.Filter; +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.engine.transformers.TransformerManager; +import io.mapsmessaging.selector.IdentifierResolver; +import io.mapsmessaging.utilities.GeoHashUtils; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertDropped; +import static io.mapsmessaging.api.transformers.TransformationAssertions.assertNotDropped; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GeoHashResolverTest extends AbstractDroppingTransformationTest { + + private static ConfigurationProperties config(String transformerName, String... kvPairs) { + ConfigurationProperties parameters = new ConfigurationProperties(); + for (int i = 0; i < kvPairs.length; i += 2) { + parameters.put(kvPairs[i], kvPairs[i + 1]); + } + + ConfigurationProperties root = new ConfigurationProperties(); + root.put("name", transformerName); + root.put("parameters", parameters); + return root; + } + + private static String twoPerSegment(String geohash) { + StringBuilder builder = new StringBuilder(); + builder.append('/'); + for (int i = 0; i < geohash.length(); i += 2) { + int end = Math.min(i + 2, geohash.length()); + builder.append(geohash, i, end); + if (end < geohash.length()) { + builder.append('/'); + } + } + return builder.toString(); + } + + @Override + protected InterServerTransformation createTransformer() { + return TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "splitHash", "true" + )); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes("{}"); + } + + @Test + void transform_withLatLon_setsDestination_usingDefaultLayout() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "6", + "layout", "chars-per-segment" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(-33.8688); + when(id.get("longitude")).thenReturn(151.2093); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String expectedSuffix = GeoHashUtils.toTopicNameGeoHash(-33.8688, 151.2093, 6); + assertEquals("/geo" + expectedSuffix, result.getDestinationName()); + } + } + + @Test + void transform_layoutRaw_usesRawGeohashPath() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "layout", "raw" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(-33.8688); + when(id.get("longitude")).thenReturn(151.2093); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String geohash = GeoHashUtils.toGeoHash(-33.8688, 151.2093, 5); + assertEquals("/" + geohash, result.getDestinationName()); + } + } + + @Test + void transform_layoutTwoPerSegment_splitsEveryTwoChars() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "layout", "two-per-segment" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(-33.8688); + when(id.get("longitude")).thenReturn(151.2093); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String geohash = GeoHashUtils.toGeoHash(-33.8688, 151.2093, 5); + String expected = "/geo" + twoPerSegment(geohash); + assertEquals(expected, result.getDestinationName()); + } + } + + @Test + void transform_unitsRad_convertsRadiansToDegrees() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "units", "rad" + )); + + double latRad = Math.toRadians(-33.8688); + double lonRad = Math.toRadians(151.2093); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(latRad); + when(id.get("longitude")).thenReturn(lonRad); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String expectedSuffix = GeoHashUtils.toTopicNameGeoHash(-33.8688, 151.2093, 5); + assertEquals("/geo" + expectedSuffix, result.getDestinationName()); + } + } + + @Test + void transform_missingKeys_onMissingSkip_leavesDestinationUnchanged() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "onMissing", "skip" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(null); + when(id.get("longitude")).thenReturn(null); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + assertEquals("/dst", result.getDestinationName()); + } + } + + @Test + void transform_missingKeys_onMissingDrop_returnsNull() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "onMissing", "drop" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(null); + when(id.get("longitude")).thenReturn(null); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertDropped(result); + } + } + + @Test + void transform_missingKeys_onMissingDefaultTo_setsDestinationFromDefault() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "4", + "onMissing", "defaultTo", + "defaultTo", "0,0" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(null); + when(id.get("longitude")).thenReturn(null); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String expectedSuffix = GeoHashUtils.toTopicNameGeoHash(0.0, 0.0, 4); + assertEquals("/geo" + expectedSuffix, result.getDestinationName()); + } + } + + @Test + void transform_usesAlternateKeys_whenPrimaryMissing() { + GeoHashResolver resolver = (GeoHashResolver) TransformerManager.getInstance().get(config( + "GeoHash", + "prefix", "/geo/{geohash}", + "latKey", "latitude", + "lonKey", "longitude", + "precision", "5", + "latKeys", "lat,latitudeDeg", + "lonKeys", "lon,longitudeDeg" + )); + + IdentifierResolver id = mock(IdentifierResolver.class, withSettings().lenient()); + when(id.get("latitude")).thenReturn(null); + when(id.get("longitude")).thenReturn(null); + when(id.get("lat")).thenReturn(-33.8688); + when(id.get("lon")).thenReturn(151.2093); + + ParsedMessage parsed = parsedMessage("/dst", mockMessage(utf8Bytes("{}"))); + + try (MockedStatic filter = mockStatic(Filter.class)) { + filter.when(() -> Filter.getTopicResolver(anyString(), any())).thenReturn(id); + + ParsedMessage result = resolver.transform("/src/topic", parsed); + + assertNotDropped(result); + + String expectedSuffix = GeoHashUtils.toTopicNameGeoHash(-33.8688, 151.2093, 5); + assertEquals("/geo" + expectedSuffix, result.getDestinationName()); + } + } + + @Test + void metadata_isStable() { + InterServerTransformation created = new GeoHashResolver(); + assertEquals("GeoHash", created.getName()); + assertNotNull(created.getDescription()); + assertFalse(created.getDescription().isBlank()); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/InterServerTransformationPipelineTest.java b/src/test/java/io/mapsmessaging/api/transformers/InterServerTransformationPipelineTest.java new file mode 100644 index 000000000..cbde979f8 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/InterServerTransformationPipelineTest.java @@ -0,0 +1,72 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.network.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.*; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.*; + +public class InterServerTransformationPipelineTest { + + private static ParsedMessage applyPipeline( + String source, + ParsedMessage message, + List transformers + ) { + ParsedMessage current = message; + for (InterServerTransformation transformer : transformers) { + if (current == null) { + return null; + } + current = transformer.transform(source, current); + } + return current; + } + + @Test + void pipeline_stopsWhenTransformerDrops() { + InterServerTransformation pass = mock(InterServerTransformation.class); + InterServerTransformation drop = mock(InterServerTransformation.class); + InterServerTransformation neverCalled = mock(InterServerTransformation.class); + + Message message = mockMessage(utf8Bytes("{\"a\":1}")); + ParsedMessage parsedMessage = parsedMessage("/dst", message); + + when(pass.transform(anyString(), any())).thenAnswer(inv -> inv.getArgument(1)); + when(drop.transform(anyString(), any())).thenReturn(null); + + ParsedMessage result = applyPipeline( + "/src", + parsedMessage, + List.of(pass, drop, neverCalled) + ); + + assertNull(result); + verify(pass, times(1)).transform(anyString(), any()); + verify(drop, times(1)).transform(anyString(), any()); + verifyNoInteractions(neverCalled); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/JSONToXMLTest.java b/src/test/java/io/mapsmessaging/api/transformers/JSONToXMLTest.java new file mode 100644 index 000000000..8148dfcba --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/JSONToXMLTest.java @@ -0,0 +1,88 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.dto.rest.config.transformer.impl.JsonToXmlTransformationDTO; +import io.mapsmessaging.network.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.*; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.*; + +class JSONToXMLTest extends AbstractInPlaceTransformationTest { + + @Override + protected InterServerTransformation createTransformer() { + return new JSONToXML(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + } + + @Test + void transform_validJsonObject_producesXml() { + byte[] before = validInputBytes(); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataChanged(before, result); + assertOpaqueDataIsXml(result); + } + + @Test + void transform_invalidJson_doesNotDrop_andLeavesPayloadUnchanged() { + byte[] before = utf8Bytes(TransformationTestVectors.INVALID_JSON); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void transform_jsonArray_doesNotDrop_andLeavesPayloadUnchanged() { + // getAsJsonObject() will throw for arrays, caught by convert() + byte[] before = utf8Bytes(TransformationTestVectors.VALID_JSON_ARRAY); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void build_returnsSameInstance() { + InterServerTransformation created = createTransformer(); + InterServerTransformation built = created.build(new JsonToXmlTransformationDTO()); + assertSame(created, built); + } + + @Test + void metadata_isStable() { + InterServerTransformation created = createTransformer(); + assertEquals("JSONToXML", created.getName()); + assertNotNull(created.getDescription()); + assertFalse(created.getDescription().isBlank()); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/JsonMutateTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/JsonMutateTransformationTest.java new file mode 100644 index 000000000..9a43b658d --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/JsonMutateTransformationTest.java @@ -0,0 +1,163 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 io.mapsmessaging.api.transformers; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.api.transformers.jsonmutate.JsonMutator; +import io.mapsmessaging.dto.rest.config.transformer.jsonmutate.JsonMutateOpDTO; +import io.mapsmessaging.dto.rest.config.transformer.jsonmutate.JsonMutateOperation; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonMutateTransformationTest { + + private static final Gson gson = new Gson(); + + @Test + void setAddsOrOverwritesAField() { + JsonMutateTransformation transformation = new JsonMutateTransformation( + new JsonMutator(List.of( + buildSet("payload.temperature", 21.5), + buildSet("payload.unit", "c") + )) + ); + + ParsedMessage input = new ParsedMessage( + "/device/sensor", + messageWithJson(""" + {"payload":{"temperature":20.0,"debug":true}} + """) + ); + + ParsedMessage output = transformation.transform("/device/sensor", input); + + assertNotNull(output); + assertJsonEquals(""" + {"payload":{"temperature":21.5,"debug":true,"unit":"c"}} + """, jsonFromMessage(output.getMessage())); + } + + @Test + void removeDeletesAFieldIfPresent() { + JsonMutateTransformation transformation = new JsonMutateTransformation( + new JsonMutator(List.of( + buildRemove("payload.debug") + )) + ); + + ParsedMessage input = new ParsedMessage( + "/device/sensor", + messageWithJson(""" + {"payload":{"temperature":20.0,"debug":true}} + """) + ); + + ParsedMessage output = transformation.transform("/device/sensor", input); + + assertNotNull(output); + assertJsonEquals(""" + {"payload":{"temperature":20.0}} + """, jsonFromMessage(output.getMessage())); + } + + @Test + void removeMissingFieldIsNoOp() { + JsonMutateTransformation transformation = new JsonMutateTransformation( + new JsonMutator(List.of( + buildRemove("$.payload.nope") + )) + ); + + ParsedMessage input = new ParsedMessage( + "/device/sensor", + messageWithJson(""" + {"payload":{"temperature":20.0}} + """) + ); + + ParsedMessage output = transformation.transform("/device/sensor", input); + + assertNotNull(output); + assertJsonEquals(""" + {"payload":{"temperature":20.0}} + """, jsonFromMessage(output.getMessage())); + } + + @Test + void invalidJsonLeavesMessageUnchanged() { + JsonMutateTransformation transformation = new JsonMutateTransformation( + new JsonMutator(List.of( + buildRemove("$.payload.debug") + )) + ); + + Message message = new MessageBuilder() + .setOpaqueData("{not-json".getBytes(StandardCharsets.UTF_8)) + .build(); + + ParsedMessage input = new ParsedMessage("/device/sensor", message); + + ParsedMessage output = transformation.transform("/device/sensor", input); + + assertNotNull(output); + assertArrayEquals( + input.getMessage().getOpaqueData(), + output.getMessage().getOpaqueData() + ); + } + + private static JsonMutateOpDTO buildSet(String path, Object value) { + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.SET); + op.setPath(path); + op.setValue(gson.toJsonTree(value)); + return op; + } + + private static JsonMutateOpDTO buildRemove(String path) { + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.REMOVE); + op.setPath(path); + return op; + } + + private static Message messageWithJson(String json) { + return new MessageBuilder() + .setOpaqueData(json.trim().getBytes(StandardCharsets.UTF_8)) + .build(); + } + + private static String jsonFromMessage(Message message) { + return new String(message.getOpaqueData(), StandardCharsets.UTF_8); + } + + private static void assertJsonEquals(String expectedJson, String actualJson) { + JsonElement expected = JsonParser.parseString(expectedJson); + JsonElement actual = JsonParser.parseString(actualJson); + assertEquals(expected, actual); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/JsonQueryTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/JsonQueryTransformationTest.java new file mode 100644 index 000000000..933bd2269 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/JsonQueryTransformationTest.java @@ -0,0 +1,137 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.engine.schema.SchemaManager; +import io.mapsmessaging.engine.transformers.TransformerManager; +import io.mapsmessaging.schemas.formatters.MessageFormatter; +import io.mapsmessaging.schemas.formatters.MessageFormatterFactory; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.*; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.*; + +class JsonQueryTransformationTest extends AbstractDroppingTransformationTest { + + private static ConfigurationProperties config(String transformerName, String... kvPairs) { + ConfigurationProperties parameters = new ConfigurationProperties(); + for (int i = 0; i < kvPairs.length; i += 2) { + parameters.put(kvPairs[i], kvPairs[i + 1]); + } + + ConfigurationProperties root = new ConfigurationProperties(); + root.put("name", transformerName); + root.put("parameters", parameters); + return root; + } + + @Override + protected InterServerTransformation createTransformer() { + return TransformerManager.getInstance().get(config( + "JsonQuery", + "query", " " + )); + } + + private void setMessageFormatter(InterServerTransformation transformation) { + try { + MessageFormatter messageFormatter = + SchemaManager.getInstance().getMessageFormatter(AbstractInterServerTransformationTest.JSON_SCHEMA_CONFIG); + ((JsonQueryTransformation) transformation).schemaMap.put(AbstractInterServerTransformationTest.SOURCE, messageFormatter); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes("{\"a\":1,\"b\":\"x\",\"nested\":{\"c\":true}}"); + } + + @Test + void build_withJsonFormatQuery_extractsValue() { + InterServerTransformation transformer = TransformerManager.getInstance().get(config( + "JsonQuery", + "query", "[\"get\",\"a\"]" + )); + setMessageFormatter(transformer); + + ParsedMessage result = transformWith(transformer, utf8Bytes("{\"a\":1,\"b\":\"x\"}")); + + assertNotDropped(result); + assertOpaqueDataEqualsUtf8(result, "1"); + } + + @Test + void build_withTextFormatQuery_extractsValue() { + InterServerTransformation transformer = TransformerManager.getInstance().get(config( + "JsonQuery", + "query", ".a" + )); + setMessageFormatter(transformer); + + ParsedMessage result = transformWith(transformer, utf8Bytes("{\"a\":1,\"b\":\"x\"}")); + + assertNotDropped(result); + assertOpaqueDataEqualsUtf8(result, "1"); + } + + @Test + void build_withBlankQuery_isTrueNoOp() { + InterServerTransformation transformer = TransformerManager.getInstance().get(config( + "JsonQuery", + "query", " " + )); + setMessageFormatter(transformer); + + byte[] before = utf8Bytes("{\"a\":1,\"b\":\"x\"}"); + ParsedMessage result = transformWith(transformer, before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void defaultInstance_programNull_isNoOp() { + byte[] before = utf8Bytes("{\"a\":1,\"b\":\"x\"}"); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void metadata_isStable() { + InterServerTransformation created = new JsonQueryTransformation(); + assertEquals("JsonQuery", created.getName()); + assertNotNull(created.getDescription()); + assertFalse(created.getDescription().isBlank()); + } + + private ParsedMessage transformWith(InterServerTransformation transformer, byte[] opaqueData) { + this.transformer = transformer; + return transform(opaqueData); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/JsonToValueTransformationTest.java b/src/test/java/io/mapsmessaging/api/transformers/JsonToValueTransformationTest.java new file mode 100644 index 000000000..983700034 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/JsonToValueTransformationTest.java @@ -0,0 +1,131 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.engine.transformers.TransformerManager; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.*; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.*; + +class JsonToValueTransformationTest extends AbstractInPlaceTransformationTest { + + private static ConfigurationProperties config(String transformerName, String... kvPairs) { + ConfigurationProperties parameters = new ConfigurationProperties(); + for (int i = 0; i < kvPairs.length; i += 2) { + parameters.put(kvPairs[i], kvPairs[i + 1]); + } + + ConfigurationProperties root = new ConfigurationProperties(); + root.put("name", transformerName); + root.put("parameters", parameters); + return root; + } + + @Override + protected InterServerTransformation createTransformer() { + // default constructor => jsonParser == null => should always pass-through unchanged + return new JsonToValueTransformation(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_JSON_OBJECT); + } + + @Test + void transform_defaultInstance_passesThroughUnchanged() { + byte[] before = utf8Bytes("{\"a\":1,\"b\":\"x\"}"); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void build_withKey_extractsValue() { + InterServerTransformation built = TransformerManager.getInstance().get(config( + "JsonToValue", + "key", "b" + )); + + ParsedMessage result = transformWith(built, utf8Bytes("{\"a\":1,\"b\":\"x\"}")); + + assertNotDropped(result); + assertOpaqueDataEqualsUtf8(result, "x"); + } + + @Test + void build_withData_fallback_extractsValue() { + InterServerTransformation built = TransformerManager.getInstance().get(config( + "JsonToValue", + "data", "a" + )); + + ParsedMessage result = transformWith(built, utf8Bytes("{\"a\":1,\"b\":\"x\"}")); + + assertNotDropped(result); + assertOpaqueDataEqualsUtf8(result, "1"); + } + + @Test + void configuredKey_missingValue_passesThroughUnchanged() { + InterServerTransformation built = TransformerManager.getInstance().get(config( + "JsonToValue", + "key", "nope" + )); + + byte[] before = utf8Bytes("{\"a\":1,\"b\":\"x\"}"); + ParsedMessage result = transformWith(built, before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void invalidJson_passesThroughUnchanged() { + InterServerTransformation built = TransformerManager.getInstance().get(config( + "JsonToValue", + "key", "a" + )); + + byte[] before = utf8Bytes(TransformationTestVectors.INVALID_JSON); + ParsedMessage result = transformWith(built, before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void metadata_isStable() { + InterServerTransformation created = createTransformer(); + assertEquals("JsonToValue", created.getName()); + assertNotNull(created.getDescription()); + assertFalse(created.getDescription().isBlank()); + } + + private ParsedMessage transformWith(InterServerTransformation transformer, byte[] opaqueData) { + this.transformer = transformer; + return transform(opaqueData); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/ProtobufSchemaToJsonTest.java b/src/test/java/io/mapsmessaging/api/transformers/ProtobufSchemaToJsonTest.java new file mode 100644 index 000000000..7bc4dd02f --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/ProtobufSchemaToJsonTest.java @@ -0,0 +1,118 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.schema.SchemaManager; +import io.mapsmessaging.network.protocol.transformation.SchemaToJsonTransformation; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class ProtobufSchemaToJsonTest extends BaseTestConfig { + + private SchemaManager schemaManager; + private UUID schemaId; + + @BeforeEach + void setUp() { + schemaManager = SchemaManager.getInstance(); + } + + @AfterEach + void tearDown() { + schemaManager.removeSchema(schemaId.toString()); + } + + @Test + void protobuf_binaryPayload_isConvertedToJson() { + schemaId = UUID.randomUUID(); + + SchemaConfig schemaConfig = TestProtobufSchemas.protobufSchemaConfig(schemaId); + schemaManager.addSchema("/protobuf", schemaConfig); + + byte[] payload = TestProtobufSchemas.encodeProtobufPayload(); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), payload); + Message message = builder.build(); + + message = transform.outgoing(message, "/protobuf"); + + assertNotNull(message); + + byte[] outPayload = message.getOpaqueData(); + JsonObject json = JsonParser.parseString(new String(outPayload, StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject payloadJson = json.getAsJsonObject("payload"); + + assertEquals("abc", payloadJson.get("id").getAsString()); + assertEquals(123, payloadJson.get("count").getAsInt()); + assertTrue(payloadJson.get("active").getAsBoolean()); + } + + @Test + void protobuf_missingSchema_dropsMessage() { + schemaId = UUID.randomUUID(); + + byte[] payload = TestProtobufSchemas.encodeProtobufPayload(); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), payload); + Message message = builder.build(); + + Message out = transform.outgoing(message, "/protobuf"); + + assertNull(out); + } + + @Test + void protobuf_truncatedPayload_dropsMessage() { + schemaId = UUID.randomUUID(); + + SchemaConfig schemaConfig = TestProtobufSchemas.protobufSchemaConfig(schemaId); + schemaManager.addSchema("/protobuf", schemaConfig); + + byte[] payload = TestProtobufSchemas.encodeProtobufPayload(); + byte[] truncated = new byte[Math.max(0, payload.length - 1)]; + System.arraycopy(payload, 0, truncated, 0, truncated.length); + + SchemaToJsonTransformation transform = new SchemaToJsonTransformation(); + + MessageBuilder builder = TestMessages.builderWithSchema(schemaId.toString(), truncated); + Message message = builder.build(); + + Message out = transform.outgoing(message, "/protobuf"); + + assertNull(out); + } + +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TestAvroSchemas.java b/src/test/java/io/mapsmessaging/api/transformers/TestAvroSchemas.java new file mode 100644 index 000000000..bea596fa3 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TestAvroSchemas.java @@ -0,0 +1,80 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonParser; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.mapsmessaging.schemas.config.impl.AvroSchemaConfig; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.BinaryEncoder; +import org.apache.avro.io.EncoderFactory; + +import java.io.ByteArrayOutputStream; +import java.util.UUID; + +public final class TestAvroSchemas { + + public static final String SCHEMA_JSON = + "{" + + "\"type\":\"record\"," + + "\"name\":\"TestEvent\"," + + "\"namespace\":\"io.mapsmessaging.test\"," + + "\"fields\":[" + + "{\"name\":\"id\",\"type\":\"string\"}," + + "{\"name\":\"count\",\"type\":\"int\"}," + + "{\"name\":\"active\",\"type\":\"boolean\"}" + + "]" + + "}"; + + private TestAvroSchemas() { + } + + public static SchemaConfig avroSchemaConfig(UUID schemaId, String schemaJson) { + AvroSchemaConfig config = new AvroSchemaConfig(); + config.setSchema(JsonParser.parseString(schemaJson).getAsJsonObject()); + config.setUniqueId(schemaId); + return config; + } + + public static byte[] encodeAvroPayload() { + try { + Schema schema = new Schema.Parser().parse(SCHEMA_JSON); + + GenericRecord record = new GenericData.Record(schema); + record.put("id", "abc"); + record.put("count", 123); + record.put("active", true); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, null); + + GenericDatumWriter writer = new GenericDatumWriter<>(schema); + writer.write(record, encoder); + encoder.flush(); + + return out.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TestMessages.java b/src/test/java/io/mapsmessaging/api/transformers/TestMessages.java new file mode 100644 index 000000000..56c03170d --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TestMessages.java @@ -0,0 +1,35 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.api.MessageBuilder; + +public final class TestMessages { + + private TestMessages() { + } + + public static MessageBuilder builderWithSchema(String schemaId, byte[] payload) { + MessageBuilder builder = new MessageBuilder(); + builder.setSchemaId(schemaId); + builder.setOpaqueData(payload); + return builder; + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TestProtobufSchemas.java b/src/test/java/io/mapsmessaging/api/transformers/TestProtobufSchemas.java new file mode 100644 index 000000000..ebe6d737d --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TestProtobufSchemas.java @@ -0,0 +1,105 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.protobuf.DescriptorProtos; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.mapsmessaging.schemas.config.impl.ProtoBufSchemaConfig; + + +import java.util.UUID; + +public final class TestProtobufSchemas { + + private TestProtobufSchemas() { + } + + public static SchemaConfig protobufSchemaConfig(UUID schemaId) { + ProtoBufSchemaConfig config = new ProtoBufSchemaConfig(); + config.setUniqueId(schemaId); + + ProtoBufSchemaConfig.ProtobufConfig pbConfig = new ProtoBufSchemaConfig.ProtobufConfig(); + pbConfig.setDescriptorValue(buildFileDescriptorSetBytes()); + pbConfig.setMessageName("TestEvent"); + config.setProtobufConfig(pbConfig); + return config; + } + + public static byte[] encodeProtobufPayload() { + try { + Descriptors.Descriptor descriptor = buildMessageDescriptor(); + + DynamicMessage message = + DynamicMessage.newBuilder(descriptor) + .setField(descriptor.findFieldByName("id"), "abc") + .setField(descriptor.findFieldByName("count"), 123) + .setField(descriptor.findFieldByName("active"), true) + .build(); + + return message.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static byte[] buildFileDescriptorSetBytes() { + DescriptorProtos.FileDescriptorSet set = + DescriptorProtos.FileDescriptorSet.newBuilder() + .addFile(buildFileDescriptorProto()) + .build(); + return set.toByteArray(); + } + + private static Descriptors.Descriptor buildMessageDescriptor() throws Descriptors.DescriptorValidationException { + DescriptorProtos.FileDescriptorProto fileProto = buildFileDescriptorProto(); + Descriptors.FileDescriptor file = + Descriptors.FileDescriptor.buildFrom(fileProto, new Descriptors.FileDescriptor[0]); + return file.findMessageTypeByName("TestEvent"); + } + + private static DescriptorProtos.FileDescriptorProto buildFileDescriptorProto() { + DescriptorProtos.DescriptorProto msg = + DescriptorProtos.DescriptorProto.newBuilder() + .setName("TestEvent") + .addField(field("id", 1, FieldDescriptorProto.Type.TYPE_STRING)) + .addField(field("count", 2, FieldDescriptorProto.Type.TYPE_INT32)) + .addField(field("active", 3, FieldDescriptorProto.Type.TYPE_BOOL)) + .build(); + + return DescriptorProtos.FileDescriptorProto.newBuilder() + .setName("TestEvent") + .setSyntax("proto3") + .setPackage("io_mapsmessaging_test") + .addMessageType(msg) + .build(); + } + + private static FieldDescriptorProto field(String name, int number, FieldDescriptorProto.Type type) { + return FieldDescriptorProto.newBuilder() + .setName(name) + .setNumber(number) + .setType(type) + .setLabel(DescriptorProtos.FieldDescriptorProto.Label.LABEL_OPTIONAL) + .build(); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TopicNameComputerTest.java b/src/test/java/io/mapsmessaging/api/transformers/TopicNameComputerTest.java new file mode 100644 index 000000000..7d7cc675f --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TopicNameComputerTest.java @@ -0,0 +1,198 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.selector.IdentifierResolver; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class TopicNameComputerTest { + + @Test + void noTokens_returnsTemplateUnchanged() { + IdentifierResolver resolver = key -> null; + + String result = TopicNameCompiler.computeTopicName("/a/b/c", "orig/topic", resolver); + + assertEquals("/a/b/c", result); + } + + @Test + void replacesSourceToken() { + IdentifierResolver resolver = key -> null; + + String result = TopicNameCompiler.computeTopicName("{source}/mav/gps", "/in/topic", resolver); + + assertEquals("/in/topic/mav/gps", result); + } + + @Test + void replacesSingleLookupToken() { + Map values = new HashMap<>(); + values.put("lookup_1", "A"); + IdentifierResolver resolver = values::get; + + String result = TopicNameCompiler.computeTopicName("/folder/{lookup_1}/x", "ignored", resolver); + + assertEquals("/folder/A/x", result); + } + + @Test + void replacesMultipleDifferentLookupTokens() { + Map values = new HashMap<>(); + values.put("lookup_1", "A"); + values.put("lookup_2", "B"); + values.put("lookup5", "Z"); + IdentifierResolver resolver = values::get; + + String template = "/folder/{lookup_1}/{lookup_2}/mav/gps/{lookup_1}/{lookup5}"; + String result = TopicNameCompiler.computeTopicName(template, "ignored", resolver); + + assertEquals("/folder/A/B/mav/gps/A/Z", result); + } + + @Test + void missingLookupUsesSubstitute() { + IdentifierResolver resolver = key -> null; + + String result = TopicNameCompiler.computeTopicName("/folder/{lookup_1}/x", "ignored", resolver); + + assertEquals("/folder/..../x", result); + } + + @Test + void missingAndPresentMix() { + Map values = new HashMap<>(); + values.put("lookup_1", "A"); + IdentifierResolver resolver = values::get; + + String result = TopicNameCompiler.computeTopicName("/{lookup_1}/{lookup_2}/{lookup_1}", "ignored", resolver); + + assertEquals("/A/..../A", result); + } + + @Test + void nullSourceReplacedAsNullStringLiteral() { + IdentifierResolver resolver = key -> null; + + String result = TopicNameCompiler.computeTopicName("{source}/x", null, resolver); + + // StringBuilder.append((Object)null) appends "null" + assertEquals("..../x", result); + } + + @Test + void unmatchedOpeningBrace_isTreatedAsLiteralAndTokenIsIgnored() { + Map values = new HashMap<>(); + values.put("lookup_1", "A"); + IdentifierResolver resolver = values::get; + + String result = TopicNameCompiler.computeTopicName("/x/{lookup_1", "ignored", resolver); + + // With the current implementation, everything after '{' is swallowed into token buffer and never flushed. + // So the result becomes "/x/". + assertEquals("/x/", result); + } + + @Test + void emptyTokenBecomesMissingSubstitute() { + IdentifierResolver resolver = key -> null; + + String result = TopicNameCompiler.computeTopicName("/x/{}", "ignored", resolver); + + assertEquals("/x/....", result); + } + + private static IdentifierResolver resolver(Map map) { + return map::get; + } + + @Test + void returnsTemplateWhenNoTokens() { + String template = "/a/b/c"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of())); + assertEquals("/a/b/c", result); + } + + @Test + void replacesSameTokenMultipleTimes() { + String template = "/x/{lookup_1}/y/{lookup_1}/z"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of("lookup_1", "VAL"))); + assertEquals("/x/VAL/y/VAL/z", result); + } + + @Test + void replacesMultipleDifferentTokens() { + String template = "/folder/{lookup_1}/{lookup_2}/mav/gps/{lookup_1}/{lookup5}"; + Map values = new HashMap<>(); + values.put("lookup_1", "one"); + values.put("lookup_2", 2); + values.put("lookup5", "five"); + + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(values)); + assertEquals("/folder/one/2/mav/gps/one/five", result); + } + + @Test + void substitutesMissingTokenWithDots() { + String template = "/a/{missing}/b"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of())); + assertEquals("/a/..../b", result); + } + + @Test + void mixesPresentAndMissingTokens() { + String template = "/a/{lookup_1}/{missing}/{lookup_2}"; + Map values = new HashMap<>(); + values.put("lookup_1", "ONE"); + values.put("lookup_2", "TWO"); + + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(values)); + assertEquals("/a/ONE/..../TWO", result); + } + + @Test + void handlesNonStringValuesViaToString() { + String template = "/a/{lookup_1}/b"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of("lookup_1", 12345))); + assertEquals("/a/12345/b", result); + } + + @Test + void leavesUnclosedTokenAsLiteralPrefix() { + // Current implementation: starts token capture at '{' and never flushes it if '}' never appears, + // meaning the '{' and token text are effectively dropped. + // This test locks in that behaviour. If you later decide to treat it as literal text, change test + code together. + String template = "/a/{lookup_1/b"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of("lookup_1", "X"))); + assertEquals("/a/", result); + } + + @Test + void treatsStrayClosingBraceAsLiteral() { + String template = "/a}/b"; + String result = TopicNameCompiler.computeTopicName(template, "/source/topic", resolver(Map.of())); + assertEquals("/a}/b", result); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TransformationAssertions.java b/src/test/java/io/mapsmessaging/api/transformers/TransformationAssertions.java new file mode 100644 index 000000000..08eb02637 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TransformationAssertions.java @@ -0,0 +1,97 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.network.protocol.Protocol; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8String; +import static org.junit.jupiter.api.Assertions.*; + +public final class TransformationAssertions { + + private TransformationAssertions() { + } + + public static void assertNotDropped(ParsedMessage result) { + assertNotNull(result, "Expected message to NOT be dropped (null indicates filtered-out event)"); + assertNotNull(result.getMessage(), "Result ParsedMessage.message must not be null"); + } + + public static void assertDropped(ParsedMessage result) { + assertNull(result, "Expected message to be dropped (null)"); + } + + public static void assertOpaqueDataEqualsUtf8(ParsedMessage result, String expected) { + assertNotDropped(result); + Message message = result.getMessage(); + byte[] actualBytes = message.getOpaqueData(); + assertNotNull(actualBytes, "opaqueData must not be null"); + assertEquals(expected, new String(actualBytes, StandardCharsets.UTF_8)); + } + + public static void assertOpaqueDataChanged(byte[] before, ParsedMessage result) { + assertNotDropped(result); + byte[] after = result.getMessage().getOpaqueData(); + assertNotNull(after, "opaqueData must not be null"); + assertNotEquals(utf8String(before), utf8String(after), "Expected opaqueData content to change"); + } + + public static void assertOpaqueDataUnchanged(byte[] before, ParsedMessage result) { + assertNotDropped(result); + byte[] after = result.getMessage().getOpaqueData(); + assertNotNull(after, "opaqueData must not be null"); + assertArrayEquals(before, after, "Expected opaqueData bytes to remain unchanged"); + } + + public static JsonElement assertOpaqueDataIsJson(ParsedMessage result) { + assertNotDropped(result); + byte[] bytes = result.getMessage().getOpaqueData(); + assertNotNull(bytes, "opaqueData must not be null"); + assertDoesNotThrow(() -> JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))); + return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + } + + public static void assertOpaqueDataIsJsonObject(ParsedMessage result) { + JsonElement element = assertOpaqueDataIsJson(result); + assertTrue(element.isJsonObject(), "Expected JSON object"); + } + + public static void assertOpaqueDataIsXml(ParsedMessage result) { + assertNotDropped(result); + byte[] bytes = result.getMessage().getOpaqueData(); + assertNotNull(bytes, "opaqueData must not be null"); + + assertDoesNotThrow(() -> { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setExpandEntityReferences(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + + factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); + }); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TransformationTestSupport.java b/src/test/java/io/mapsmessaging/api/transformers/TransformationTestSupport.java new file mode 100644 index 000000000..6882e1bdb --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TransformationTestSupport.java @@ -0,0 +1,71 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.configuration.ConfigurationProperties; +import io.mapsmessaging.network.protocol.Protocol; + +import java.nio.charset.StandardCharsets; + +import static org.mockito.Mockito.*; + +public final class TransformationTestSupport { + + private TransformationTestSupport() { + } + + public static ParsedMessage parsedMessage(String destinationName, Message message) { + ParsedMessage parsedMessage = new ParsedMessage(); + parsedMessage.setDestinationName(destinationName); + parsedMessage.setMessage(message); + return parsedMessage; + } + + public static Message mockMessage(byte[] opaqueData) { + Message message = mock(Message.class, withSettings().lenient()); + when(message.getOpaqueData()).thenReturn(opaqueData); + return message; + } + + public static Message mockMessage(byte[] opaqueData, String schemaId) { + Message message = mockMessage(opaqueData); + when(message.getSchemaId()).thenReturn(schemaId); + return message; + } + + public static byte[] utf8Bytes(String value) { + if (value == null) { + return null; + } + return value.getBytes(StandardCharsets.UTF_8); + } + + public static String utf8String(byte[] value) { + if (value == null) { + return null; + } + return new String(value, StandardCharsets.UTF_8); + } + + public static ConfigurationProperties emptyProperties() { + return new ConfigurationProperties(); + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/TransformationTestVectors.java b/src/test/java/io/mapsmessaging/api/transformers/TransformationTestVectors.java new file mode 100644 index 000000000..453c94bd5 --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/TransformationTestVectors.java @@ -0,0 +1,38 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +public final class TransformationTestVectors { + + public static final String VALID_JSON_OBJECT = "{\"a\":1,\"b\":\"x\",\"nested\":{\"c\":true}}"; + public static final String VALID_JSON_OBJECT_MINIMAL = "{\"a\":1}"; + public static final String VALID_JSON_ARRAY = "[1,2,3]"; + public static final String VALID_JSON_STRING = "\"hello\""; + public static final String INVALID_JSON = "{a:1"; + public static final String NON_JSON_TEXT = "not json at all"; + public static final String VALID_XML_SIMPLE = + "1xtrue"; + public static final String INVALID_XML = + "1"; + public static final byte[] EMPTY_BYTES = new byte[0]; + + private TransformationTestVectors() { + } +} diff --git a/src/test/java/io/mapsmessaging/api/transformers/XMLToJSONTest.java b/src/test/java/io/mapsmessaging/api/transformers/XMLToJSONTest.java new file mode 100644 index 000000000..ef826584e --- /dev/null +++ b/src/test/java/io/mapsmessaging/api/transformers/XMLToJSONTest.java @@ -0,0 +1,79 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.api.transformers; + +import io.mapsmessaging.dto.rest.config.transformer.impl.XmlToJsonTransformationDTO; +import io.mapsmessaging.network.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.api.transformers.TransformationAssertions.*; +import static io.mapsmessaging.api.transformers.TransformationTestSupport.utf8Bytes; +import static org.junit.jupiter.api.Assertions.*; + +class XMLToJSONTest extends AbstractInPlaceTransformationTest { + + + @Override + protected InterServerTransformation createTransformer() { + return new XMLToJSON(); + } + + @Override + protected byte[] validInputBytes() { + return utf8Bytes(TransformationTestVectors.VALID_XML_SIMPLE); + } + + @Test + void transform_validXml_producesJsonObject() { + byte[] before = validInputBytes(); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataChanged(before, result); + assertOpaqueDataIsJsonObject(result); + } + + @Test + void transform_invalidXml_doesNotDrop_andLeavesPayloadUnchanged() { + byte[] before = utf8Bytes(TransformationTestVectors.INVALID_XML); + + ParsedMessage result = transform(before); + + assertNotDropped(result); + assertOpaqueDataUnchanged(before, result); + } + + @Test + void build_returnsSameInstance() { + InterServerTransformation created = createTransformer(); + InterServerTransformation built = created.build(new XmlToJsonTransformationDTO()); + assertSame(created, built); + } + + @Test + void metadata_isStable() { + InterServerTransformation created = createTransformer(); + assertEquals("XMLToJSON", created.getName()); + assertNotNull(created.getDescription()); + assertFalse(created.getDescription().isBlank()); + } + +} diff --git a/src/test/java/io/mapsmessaging/dto/rest/config/transformer/TransformationConfigDtoGsonPolymorphismTest.java b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/TransformationConfigDtoGsonPolymorphismTest.java new file mode 100644 index 000000000..c2241969d --- /dev/null +++ b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/TransformationConfigDtoGsonPolymorphismTest.java @@ -0,0 +1,266 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 io.mapsmessaging.dto.rest.config.transformer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import io.mapsmessaging.dto.rest.config.transformer.gson.TransformationConfigDtoTypeAdapterFactory; +import io.mapsmessaging.dto.rest.config.transformer.impl.*; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashLayout; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashOnMissingPolicy; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashUnits; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TransformationConfigDtoGsonPolymorphismTest { + + private static Gson buildGson() { + return new GsonBuilder() + .registerTypeAdapterFactory(new TransformationConfigDtoTypeAdapterFactory()) + .create(); + } + + @Test + void jsonMutateDeserializes() { + Gson gson = buildGson(); + + String json = """ + {"type":"jsonmutate"} + """; + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + + assertNotNull(dto); + assertTrue(dto instanceof JsonMutateTransformationDTO); + + JsonMutateTransformationDTO mutate = (JsonMutateTransformationDTO) dto; + assertEquals(TransformationType.JSON_MUTATE, mutate.getType()); + } + + @Test + void jsonGeneralDeserializes() { + Gson gson = buildGson(); + for(TransformationType type : TransformationType.values()){ + String json = """ + {"type":"%s"} + """.formatted(type.getWireName().toLowerCase()); + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + assertNotNull(dto); + assertEquals(type, dto.getType()); + } + } + + + @Test + void jsonMutateDiscriminatorIsCaseInsensitive() { + Gson gson = buildGson(); + + String json = """ + {"type":"JsOnMuTaTe"} + """; + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + + assertNotNull(dto); + assertTrue(dto instanceof JsonMutateTransformationDTO); + + JsonMutateTransformationDTO mutate = (JsonMutateTransformationDTO) dto; + assertEquals(TransformationType.JSON_MUTATE, mutate.getType()); + } + + @Test + void deserializeMixedListParsesConcreteTypes() { + Gson gson = buildGson(); + Type listType = new TypeToken>() {}.getType(); + + String json = """ + [ + {"type":"jsontoxml"}, + {"type":"xmltojson"}, + {"type":"jsontovalue","key":"data.temperature"}, + {"type":"jsonquery","query":"."}, + { + "type":"geohash", + "prefix":"maps/location", + "latKey":"latitude", + "lonKey":"longitude", + "precision":7, + "units":"deg", + "layout":"raw", + "onMissing":"skip", + "latKeys":["lat","gps.lat"], + "lonKeys":["lon","gps.lon"] + } + ] + """; + + List list = gson.fromJson(json, listType); + + assertNotNull(list); + assertEquals(5, list.size()); + + assertTrue(list.get(0) instanceof JsonToXmlTransformationDTO); + assertTrue(list.get(1) instanceof XmlToJsonTransformationDTO); + assertTrue(list.get(2) instanceof JsonToValueTransformationDTO); + assertTrue(list.get(3) instanceof JsonQueryTransformationDTO); + assertTrue(list.get(4) instanceof GeoHashResolverTransformationDTO); + + JsonToValueTransformationDTO jsonToValue = (JsonToValueTransformationDTO) list.get(2); + assertEquals("data.temperature", jsonToValue.getKey()); + + JsonQueryTransformationDTO jsonQuery = (JsonQueryTransformationDTO) list.get(3); + assertEquals(".", jsonQuery.getQuery()); + + GeoHashResolverTransformationDTO geohash = (GeoHashResolverTransformationDTO) list.get(4); + assertEquals("maps/location", geohash.getPrefix()); + assertEquals("latitude", geohash.getLatKey()); + assertEquals("longitude", geohash.getLonKey()); + assertEquals(7, geohash.getPrecision()); + assertEquals(GeoHashUnits.DEG, geohash.getUnits()); + assertEquals(GeoHashLayout.RAW, geohash.getLayout()); + assertEquals(GeoHashOnMissingPolicy.SKIP, geohash.getOnMissing()); + assertEquals(List.of("lat", "gps.lat"), geohash.getLatKeys()); + assertEquals(List.of("lon", "gps.lon"), geohash.getLonKeys()); + } + + @Test + void discriminatorIsCaseInsensitive() { + Gson gson = buildGson(); + + String json = """ + {"type":"GeOHaSh","precision":6,"units":"deg","layout":"raw","onMissing":"skip"} + """; + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + + assertNotNull(dto); + assertTrue(dto instanceof GeoHashResolverTransformationDTO); + + GeoHashResolverTransformationDTO geohash = (GeoHashResolverTransformationDTO) dto; + assertEquals(TransformationType.GEOHASH, geohash.getType()); + assertEquals(6, geohash.getPrecision()); + } + + @Test + void unknownTypeThrows() { + Gson gson = buildGson(); + + String json = """ + {"type":"nope-not-a-real-transformer"} + """; + + assertThrows(JsonParseException.class, () -> gson.fromJson(json, TransformationConfigDTO.class)); + } + + @Test + void missingTypeThrows() { + Gson gson = buildGson(); + + String json = """ + {"precision":5} + """; + + assertThrows(JsonParseException.class, () -> gson.fromJson(json, TransformationConfigDTO.class)); + } + + @Test + void trivialTransformersDeserialize() { + Gson gson = buildGson(); + + TransformationConfigDTO a = gson.fromJson("{\"type\":\"jsontoxml\"}", TransformationConfigDTO.class); + TransformationConfigDTO b = gson.fromJson("{\"type\":\"xmltojson\"}", TransformationConfigDTO.class); + + assertTrue(a instanceof JsonToXmlTransformationDTO); + assertTrue(b instanceof XmlToJsonTransformationDTO); + + assertEquals(TransformationType.JSON_TO_XML, a.getType()); + assertEquals(TransformationType.XML_TO_JSON, b.getType()); + } + + @Test + void invalidEnumValueResultsInNull() { + Gson gson = buildGson(); + + String json = """ + {"type":"geohash","units":"bananas"} + """; + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + + assertTrue(dto instanceof GeoHashResolverTransformationDTO); + GeoHashResolverTransformationDTO geohash = + (GeoHashResolverTransformationDTO) dto; + + assertNull(geohash.getUnits()); + } + + @Test + void defaultsAppliedWhenFieldsMissing() { + Gson gson = buildGson(); + + String json = """ + {"type":"geohash"} + """; + + TransformationConfigDTO dto = gson.fromJson(json, TransformationConfigDTO.class); + + assertNotNull(dto); + assertTrue(dto instanceof GeoHashResolverTransformationDTO); + + GeoHashResolverTransformationDTO geohash = (GeoHashResolverTransformationDTO) dto; + assertEquals(TransformationType.GEOHASH, geohash.getType()); + assertEquals(GeoHashUnits.DEG, geohash.getUnits()); + assertEquals(GeoHashLayout.CHARS_PER_SEGMENT, geohash.getLayout()); + assertEquals(GeoHashOnMissingPolicy.SKIP, geohash.getOnMissing()); + } + + @Test + void roundTripSerializationKeepsTypeAndConcreteClass() { + Gson gson = buildGson(); + + GeoHashResolverTransformationDTO original = new GeoHashResolverTransformationDTO(); + original.setPrecision(6); + original.setUnits(GeoHashUnits.DEG); + original.setLayout(GeoHashLayout.RAW); + original.setOnMissing(GeoHashOnMissingPolicy.SKIP); + + String json = gson.toJson(original); + assertTrue(json.contains("\"type\"")); + + TransformationConfigDTO parsed = gson.fromJson(json, TransformationConfigDTO.class); + assertNotNull(parsed); + assertTrue(parsed instanceof GeoHashResolverTransformationDTO); + + GeoHashResolverTransformationDTO geohash = (GeoHashResolverTransformationDTO) parsed; + assertEquals(TransformationType.GEOHASH, geohash.getType()); + assertEquals(6, geohash.getPrecision()); + assertEquals(GeoHashUnits.DEG, geohash.getUnits()); + assertEquals(GeoHashLayout.RAW, geohash.getLayout()); + assertEquals(GeoHashOnMissingPolicy.SKIP, geohash.getOnMissing()); + } + + +} diff --git a/src/test/java/io/mapsmessaging/dto/rest/config/transformer/impl/GeoHashResolverTransformationDtoDefaultsTest.java b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/impl/GeoHashResolverTransformationDtoDefaultsTest.java new file mode 100644 index 000000000..27737c112 --- /dev/null +++ b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/impl/GeoHashResolverTransformationDtoDefaultsTest.java @@ -0,0 +1,96 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 io.mapsmessaging.dto.rest.config.transformer.impl; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.mapsmessaging.dto.rest.config.transformer.gson.TransformationConfigDtoTypeAdapterFactory; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashLayout; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashOnMissingPolicy; +import io.mapsmessaging.dto.rest.config.transformer.impl.geohash.GeoHashUnits; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class GeoHashResolverTransformationDtoDefaultsTest { + + private static Gson buildGson() { + return new GsonBuilder() + .registerTypeAdapterFactory(new TransformationConfigDtoTypeAdapterFactory()) + .create(); + } + + @Test + void defaultsArePresentWhenFieldsOmitted() { + Gson gson = buildGson(); + + String json = """ + {"type":"geohash"} + """; + + GeoHashResolverTransformationDTO dto = (GeoHashResolverTransformationDTO) gson.fromJson(json, io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO.class); + + assertNotNull(dto); + + assertEquals("", dto.getPrefix()); + assertEquals("latitude", dto.getLatKey()); + assertEquals("longitude", dto.getLonKey()); + + assertEquals(5, dto.getPrecision()); + + assertNotNull(dto.getLatKeys()); + assertNotNull(dto.getLonKeys()); + assertTrue(dto.getLatKeys().isEmpty()); + assertTrue(dto.getLonKeys().isEmpty()); + + assertEquals(GeoHashUnits.DEG, dto.getUnits()); + assertEquals(GeoHashLayout.CHARS_PER_SEGMENT, dto.getLayout()); + assertEquals(GeoHashOnMissingPolicy.SKIP, dto.getOnMissing()); + + assertNull(dto.getDefaultLatitude()); + assertNull(dto.getDefaultLongitude()); + } + + @Test + void enumValuesDeserialize() { + Gson gson = buildGson(); + + String json = """ + { + "type":"geohash", + "units":"e7", + "layout":"two-per-segment", + "onMissing":"defaultTo", + "defaultLatitude":0.0, + "defaultLongitude":0.0 + } + """; + + GeoHashResolverTransformationDTO dto = (GeoHashResolverTransformationDTO) gson.fromJson(json, io.mapsmessaging.dto.rest.config.transformer.TransformationConfigDTO.class); + + assertNotNull(dto); + + assertEquals(GeoHashUnits.E7, dto.getUnits()); + assertEquals(GeoHashLayout.TWO_PER_SEGMENT, dto.getLayout()); + assertEquals(GeoHashOnMissingPolicy.DEFAULT_TO, dto.getOnMissing()); + + assertEquals(0.0, dto.getDefaultLatitude()); + assertEquals(0.0, dto.getDefaultLongitude()); + } +} diff --git a/src/test/java/io/mapsmessaging/dto/rest/config/transformer/jsonmutate/JsonMutatorTest.java b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/jsonmutate/JsonMutatorTest.java new file mode 100644 index 000000000..98d272027 --- /dev/null +++ b/src/test/java/io/mapsmessaging/dto/rest/config/transformer/jsonmutate/JsonMutatorTest.java @@ -0,0 +1,124 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 io.mapsmessaging.dto.rest.config.transformer.jsonmutate; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.List; + +import io.mapsmessaging.api.transformers.jsonmutate.JsonMutator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class JsonMutatorTest { + + @Test + void setCreatesPathAndSetsValue() { + JsonObject root = new JsonObject(); + + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.SET); + op.setPath("payload.temperature"); + op.setValue(new JsonPrimitive(23.5)); + + JsonMutator mutator = new JsonMutator(List.of(op)); + JsonObject mutated = mutator.apply(root); + + assertNotNull(mutated.getAsJsonObject("payload")); + assertEquals(23.5, mutated.getAsJsonObject("payload").get("temperature").getAsDouble(), 0.00001); + } + + @Test + void removeDeletesFieldWhenPresent() { + JsonObject root = new JsonObject(); + JsonObject payload = new JsonObject(); + payload.add("debug", new JsonPrimitive(true)); + root.add("payload", payload); + + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.REMOVE); + op.setPath("payload.debug"); + + JsonMutator mutator = new JsonMutator(List.of(op)); + JsonObject mutated = mutator.apply(root); + + assertFalse(mutated.getAsJsonObject("payload").has("debug")); + } + + @Test + void renameMovesValue() { + JsonObject root = new JsonObject(); + JsonObject payload = new JsonObject(); + payload.add("tempC", new JsonPrimitive(21)); + root.add("payload", payload); + + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.RENAME); + op.setFrom("payload.tempC"); + op.setTo("payload.temperatureC"); + + JsonMutator mutator = new JsonMutator(List.of(op)); + JsonObject mutated = mutator.apply(root); + + assertFalse(mutated.getAsJsonObject("payload").has("tempC")); + assertEquals(21, mutated.getAsJsonObject("payload").get("temperatureC").getAsInt()); + } + + @Test + void removeMissingPathIsNoOp() { + JsonObject root = new JsonObject(); + + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.REMOVE); + op.setPath("payload.missing"); + + JsonMutator mutator = new JsonMutator(List.of(op)); + JsonObject mutated = mutator.apply(root); + + assertEquals(root, mutated); + } + + @Test + void setArrayIndexCreatesArray() { + JsonObject root = new JsonObject(); + + JsonMutateOpDTO op = new JsonMutateOpDTO(); + op.setOp(JsonMutateOperation.SET); + op.setPath("payload.items[1].name"); + op.setValue(new JsonPrimitive("x")); + + JsonMutator mutator = new JsonMutator(List.of(op)); + JsonObject mutated = mutator.apply(root); + + assertTrue(mutated.getAsJsonObject("payload").get("items").isJsonArray()); + assertTrue(mutated.getAsJsonObject("payload").getAsJsonArray("items").get(0) instanceof JsonNull); + assertEquals( + "x", + mutated.getAsJsonObject("payload") + .getAsJsonArray("items") + .get(1) + .getAsJsonObject() + .get("name") + .getAsString() + ); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/MessageOverridesTest.java b/src/test/java/io/mapsmessaging/engine/destination/MessageOverridesTest.java new file mode 100644 index 000000000..cf7ff50eb --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/MessageOverridesTest.java @@ -0,0 +1,176 @@ +package io.mapsmessaging.engine.destination; + +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.features.Priority; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.api.message.TypedData; +import io.mapsmessaging.dto.rest.config.destination.MessageOverrideDTO; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.LinkedHashMap; +import java.util.Map; + +class MessageOverridesTest { + + private Message baseMessage() { + Map meta = new LinkedHashMap<>(); + meta.put("m1", "v1"); + + Map data = new LinkedHashMap<>(); + data.put("d1", new TypedData("x")); + + MessageBuilder builder = new MessageBuilder() + .setCorrelationData("corr") + .setOpaqueData(new byte[]{1, 2, 3}) + .setContentType("application/test") + .setResponseTopic("resp/topic") + .setPriority(Priority.NORMAL) + .setQoS(QualityOfService.AT_MOST_ONCE) + .setMeta(meta) + .setDataMap(data) + .setPayloadIndicator(true) + .setRetain(false) + .setSchemaId("schema-1"); + + builder.setExpiry(5000); // interval style according to calculateExpiry() + return builder.build(); + } + + @Test + void setOverrides_nullOverride_returnsSameInstance() { + Message message = baseMessage(); + + Message result = MessageOverrides.setOverrides(null, message); + + Assertions.assertSame(message, result); + } + + @Test + void createMessageBuilder_nullOverride_returnsSameBuilder() { + MessageBuilder builder = new MessageBuilder().setCorrelationData("c"); + + MessageBuilder result = MessageOverrides.createMessageBuilder(null, builder); + + Assertions.assertSame(builder, result); + } + + @Test + void applyOverrides_setsScalarFields_whenPresent() { + MessageOverrideDTO override = Mockito.mock(MessageOverrideDTO.class); + + Mockito.when(override.getQualityOfService()).thenReturn(QualityOfService.AT_LEAST_ONCE); + Mockito.when(override.getPriority()).thenReturn(Priority.HIGHEST); + Mockito.when(override.getContentType()).thenReturn("application/json"); + Mockito.when(override.getResponseTopic()).thenReturn("reply/to"); + Mockito.when(override.getExpiry()).thenReturn(1234L); + Mockito.when(override.getRetain()).thenReturn(Boolean.TRUE); + Mockito.when(override.getSchemaId()).thenReturn("schema-2"); + Mockito.when(override.getDataMap()).thenReturn(null); + Mockito.when(override.getMeta()).thenReturn(null); + + MessageBuilder builder = new MessageBuilder().setCorrelationData("c"); + builder.setExpiry(0); + + MessageBuilder updated = MessageOverrides.createMessageBuilder(override, builder); + + Assertions.assertEquals(QualityOfService.AT_LEAST_ONCE, updated.getQualityOfService()); + Assertions.assertEquals(Priority.HIGHEST, updated.getPriority()); + Assertions.assertEquals("application/json", updated.getContentType()); + Assertions.assertEquals("reply/to", updated.getResponseTopic()); + Assertions.assertEquals(1234L, updated.getExpiry()); + Assertions.assertTrue(updated.isRetain()); + Assertions.assertEquals("schema-2", updated.getSchemaId()); + } + + @Test + void applyOverrides_mergesMeta_andDataMap_creatingMapsWhenNull() { + MessageOverrideDTO override = Mockito.mock(MessageOverrideDTO.class); + + Mockito.when(override.getQualityOfService()).thenReturn(null); + Mockito.when(override.getPriority()).thenReturn(null); + Mockito.when(override.getContentType()).thenReturn(null); + Mockito.when(override.getResponseTopic()).thenReturn(null); + Mockito.when(override.getExpiry()).thenReturn(-1L); + Mockito.when(override.getRetain()).thenReturn(null); + Mockito.when(override.getSchemaId()).thenReturn(null); + + Map overrideData = new LinkedHashMap<>(); + overrideData.put("d1", "override"); // overwrite existing + overrideData.put("d2", 42L); + + Map overrideMeta = new LinkedHashMap<>(); + overrideMeta.put("m2", "v2"); + + Mockito.when(override.getDataMap()).thenReturn(overrideData); + Mockito.when(override.getMeta()).thenReturn(overrideMeta); + + Message message = baseMessage(); + + Message updated = MessageOverrides.setOverrides(override, message); + + Assertions.assertEquals("v1", updated.getMeta().get("m1")); + Assertions.assertEquals("v2", updated.getMeta().get("m2")); + + Assertions.assertEquals("override", updated.getDataMap().get("d1").getData()); + Assertions.assertEquals(42L, updated.getDataMap().get("d2").getData()); + } + + @Test + void setOverrides_doesNotChangeExpiryOrDelay_whenOverrideDoesNotSetThem() { + Message message = new MessageBuilder() + .setCorrelationData("corr") + .setOpaqueData(new byte[]{1, 2, 3}) + .setExpiry(120_000) // 10 seconds TTL (duration) + .setDelayed(2_000) // 2 seconds delay (duration) + .build(); + + long originalExpiryAbsolute = message.getExpiry(); + long originalDelayedAbsolute = message.getDelayed(); + + MessageOverrideDTO override = Mockito.mock(MessageOverrideDTO.class); + Mockito.when(override.getExpiry()).thenReturn(-1L); // not specified + Mockito.when(override.getMeta()).thenReturn(Map.of("tag", "x")); + Mockito.when(override.getDataMap()).thenReturn(null); + Mockito.when(override.getQualityOfService()).thenReturn(null); + Mockito.when(override.getPriority()).thenReturn(null); + Mockito.when(override.getContentType()).thenReturn(null); + Mockito.when(override.getResponseTopic()).thenReturn(null); + Mockito.when(override.getRetain()).thenReturn(null); + Mockito.when(override.getSchemaId()).thenReturn(null); + + Message updated = MessageOverrides.setOverrides(override, message); + + long val = Math.abs(updated.getExpiry() - originalExpiryAbsolute); + // "absolute times" should remain basically identical; tolerate small execution jitter. + Assertions.assertTrue(val <= 2000, "Expiry absolute time should be preserved when override does not set expiry "+val); + Assertions.assertTrue(Math.abs(updated.getDelayed() - originalDelayedAbsolute) <= 50, "Delayed absolute time should be preserved when override does not set delayed"); + } + + @Test + void setOverrides_preservesIdentifier() { + Message message = new MessageBuilder() + .setId(12345L) + .setCorrelationData("corr") + .setOpaqueData(new byte[]{1}) + .setExpiry(10_000) + .build(); + + MessageOverrideDTO override = Mockito.mock(MessageOverrideDTO.class); + Mockito.when(override.getExpiry()).thenReturn(-1L); + Mockito.when(override.getMeta()).thenReturn(Map.of("tag", "x")); + Mockito.when(override.getDataMap()).thenReturn(null); + Mockito.when(override.getQualityOfService()).thenReturn(null); + Mockito.when(override.getPriority()).thenReturn(null); + Mockito.when(override.getContentType()).thenReturn(null); + Mockito.when(override.getResponseTopic()).thenReturn(null); + Mockito.when(override.getRetain()).thenReturn(null); + Mockito.when(override.getSchemaId()).thenReturn(null); + + Message updated = MessageOverrides.setOverrides(override, message); + + Assertions.assertEquals(0L, updated.getIdentifier(), "Identifier must be reset by overrides"); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/RetainManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/RetainManagerTest.java new file mode 100644 index 000000000..72c8a1176 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/RetainManagerTest.java @@ -0,0 +1,261 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +class RetainManagerTest { + + @TempDir + Path tempDirectory; + + @Test + void current_onNewInstance_returnsMinusOne() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, tempDirectory.toString()); + + long currentIdFirst = retainManager.current(); + Assertions.assertEquals(-1L, currentIdFirst); + + long currentIdSecond = retainManager.current(); + Assertions.assertEquals(-1L, currentIdSecond); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_withValidIds_setsAndOverwritesRetainId_happyPath() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, null); + + long oldIdFirst = retainManager.replace(10L); + Assertions.assertEquals(-1L, oldIdFirst); + Assertions.assertEquals(10L, retainManager.current()); + + long oldIdSecond = retainManager.replace(20L); + Assertions.assertEquals(10L, oldIdSecond); + Assertions.assertEquals(20L, retainManager.current()); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_doesNotLoseOldValueEvenAfterCurrentCaches() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, tempDirectory.toString()); + + retainManager.replace(55L); + + long cached = retainManager.current(); + Assertions.assertEquals(55L, cached); + + long oldId = retainManager.replace(66L); + Assertions.assertEquals(55L, oldId); + Assertions.assertEquals(66L, retainManager.current()); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void nonPersistent_doesNotSurviveRestart() throws IOException { + Path path = tempDirectory.resolve("nonpersistent"); + Files.createDirectories(path); + + RetainManager first = null; + RetainManager second = null; + try { + first = new RetainManager(false, path.toString()); + first.replace(99L); + Assertions.assertEquals(99L, first.current()); + } finally { + if (first != null) { + first.close(); + } + } + + try { + second = new RetainManager(false, path.toString()); + Assertions.assertEquals(-1L, second.current()); + } finally { + if (second != null) { + second.close(); + } + } + } + + @Test + void persistent_survivesRestart() throws IOException { + Path path = tempDirectory.resolve("persistent"); + Files.createDirectories(path); + + RetainManager first = null; + RetainManager second = null; + try { + first = new RetainManager(true, path.toString()); + first.replace(77L); + Assertions.assertEquals(77L, first.current()); + } finally { + if (first != null) { + first.close(); + } + } + + try { + second = new RetainManager(true, path.toString()); + Assertions.assertEquals(77L, second.current()); + } finally { + if (second != null) { + second.close(); + } + } + } + + @Test + void persistent_createsDirectoryIfMissing() throws IOException { + Path missingPath = tempDirectory.resolve("willBeCreated"); + Assertions.assertFalse(Files.exists(missingPath)); + + RetainManager retainManager = null; + try { + retainManager = new RetainManager(true, missingPath.toString()); + + Assertions.assertTrue(Files.exists(missingPath)); + Assertions.assertTrue(Files.isDirectory(missingPath)); + + long current = retainManager.current(); + Assertions.assertEquals(-1L, current); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_rejectsNegativeValuesLessThanMinusOne() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, tempDirectory.toString()); + RetainManager retainManager2 = retainManager; + Assertions.assertThrows(IllegalArgumentException.class, () -> retainManager2.replace(-2L)); + Assertions.assertThrows(IllegalArgumentException.class, () -> retainManager2.replace(Long.MIN_VALUE)); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_withValidIds_setsAndOverwritesRetainId() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, null); + + long oldFirst = retainManager.replace(10L); + Assertions.assertEquals(-1L, oldFirst); + Assertions.assertEquals(10L, retainManager.current()); + + long oldSecond = retainManager.replace(20L); + Assertions.assertEquals(10L, oldSecond); + Assertions.assertEquals(20L, retainManager.current()); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_rejectsMinusOne() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, null); + RetainManager retainManager2 = retainManager; + Assertions.assertThrows(IllegalArgumentException.class, () -> retainManager2.replace(-2L)); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void replace_acceptsZeroAndLargePositiveIds() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, tempDirectory.toString()); + + Assertions.assertEquals(-1L, retainManager.replace(0L)); + Assertions.assertEquals(0L, retainManager.current()); + + long large = Long.MAX_VALUE; + Assertions.assertEquals(0L, retainManager.replace(large)); + Assertions.assertEquals(large, retainManager.current()); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + + @Test + void nonPersistent_nullPath_isAllowed() throws IOException { + RetainManager retainManager = null; + try { + retainManager = new RetainManager(false, null); + + Assertions.assertEquals(-1L, retainManager.current()); + Assertions.assertEquals(-1L, retainManager.replace(0L)); + Assertions.assertEquals(0L, retainManager.current()); + } finally { + if (retainManager != null) { + retainManager.close(); + } + } + } + + @Test + void close_canBeCalledAfterUse() throws IOException { + RetainManager retainManager = new RetainManager(false, tempDirectory.toString()); + retainManager.replace(1L); + retainManager.current(); + retainManager.close(); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/delayed/DelayedBucketTest.java b/src/test/java/io/mapsmessaging/engine/destination/delayed/DelayedBucketTest.java new file mode 100644 index 000000000..69975d173 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/delayed/DelayedBucketTest.java @@ -0,0 +1,159 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.delayed; + +import io.mapsmessaging.utilities.collections.bitset.BitSetFactory; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactoryImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Queue; + +class DelayedBucketTest { + + @Test + void newBucket_isEmpty_sizeZero_peekMinusOne() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + Assertions.assertTrue(bucket.isEmpty()); + Assertions.assertEquals(0, bucket.size()); + Assertions.assertEquals(-1L, bucket.peek()); + } + + @Test + void register_addsMessageIds_andPeekReturnsLowest() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + Assertions.assertTrue(bucket.register(9L)); + Assertions.assertTrue(bucket.register(7L)); + Assertions.assertTrue(bucket.register(8L)); + + Assertions.assertFalse(bucket.isEmpty()); + Assertions.assertEquals(3, bucket.size()); + Assertions.assertEquals(7L, bucket.peek()); + } + + @Test + void register_duplicateId_doesNotIncreaseSize() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + Assertions.assertTrue(bucket.register(42L)); + Assertions.assertEquals(1, bucket.size()); + + boolean second = bucket.register(42L); + Assertions.assertFalse(second); + Assertions.assertEquals(1, bucket.size()); + Assertions.assertEquals(42L, bucket.peek()); + } + + @Test + void remove_existing_returnsTrue_andUpdatesPeekAndSize() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + bucket.register(10L); + bucket.register(12L); + bucket.register(11L); + + Assertions.assertEquals(3, bucket.size()); + Assertions.assertEquals(10L, bucket.peek()); + + Assertions.assertTrue(bucket.remove(10L)); + Assertions.assertEquals(2, bucket.size()); + Assertions.assertEquals(11L, bucket.peek()); + + Assertions.assertTrue(bucket.remove(11L)); + Assertions.assertEquals(1, bucket.size()); + Assertions.assertEquals(12L, bucket.peek()); + + Assertions.assertTrue(bucket.remove(12L)); + Assertions.assertEquals(0, bucket.size()); + Assertions.assertTrue(bucket.isEmpty()); + Assertions.assertEquals(-1L, bucket.peek()); + } + + @Test + void remove_missing_returnsFalse_andDoesNotChangeState() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + bucket.register(5L); + + Assertions.assertFalse(bucket.remove(999L)); + Assertions.assertEquals(1, bucket.size()); + Assertions.assertEquals(5L, bucket.peek()); + } + + @Test + void getQueue_returnsBackingQueue_andOperationsAreVisible() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + Queue queue = bucket.getQueue(); + + Assertions.assertTrue(queue.offer(3L)); + Assertions.assertTrue(queue.offer(1L)); + Assertions.assertTrue(queue.offer(2L)); + + Assertions.assertEquals(3, bucket.size()); + Assertions.assertEquals(1L, bucket.peek()); + + Assertions.assertTrue(queue.remove(1L)); + Assertions.assertEquals(2, bucket.size()); + Assertions.assertEquals(2L, bucket.peek()); + } + + @Test + void queuePoll_removesInNaturalOrder() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + DelayedBucket bucket = new DelayedBucket(100L, factory); + + bucket.register(9L); + bucket.register(7L); + bucket.register(8L); + + Queue queue = bucket.getQueue(); + + Assertions.assertEquals(7L, queue.poll()); + Assertions.assertEquals(8L, queue.poll()); + Assertions.assertEquals(9L, queue.poll()); + Assertions.assertNull(queue.poll()); + + Assertions.assertEquals(0, bucket.size()); + Assertions.assertTrue(bucket.isEmpty()); + Assertions.assertEquals(-1L, bucket.peek()); + } + + @Test + void delayTime_castToInt_isTruncating_byDesign() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + + long delayTime = (long) Integer.MAX_VALUE + 100L; + DelayedBucket bucket = new DelayedBucket(delayTime, factory); + + // We can’t directly read the stored int, but we can at least assert the object is usable. + // This test exists to document the cast behaviour (and remind future you to not “forget” it). + Assertions.assertTrue(bucket.isEmpty()); + Assertions.assertEquals(0, bucket.size()); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/delayed/MessageManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/delayed/MessageManagerTest.java new file mode 100644 index 000000000..77663dcb4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/delayed/MessageManagerTest.java @@ -0,0 +1,243 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.delayed; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactory; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactoryImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Queue; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MessageManagerTest { + + @Test + void newManager_startsEmpty() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + Assertions.assertEquals(0, manager.size()); + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertTrue(manager.getBucketIds().isEmpty()); + } + + @Test + void register_createsBucket_andBucketIdsAreSorted() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(200L, newMessage(10L)); + manager.register(100L, newMessage(11L)); + manager.register(150L, newMessage(12L)); + + List bucketIds = manager.getBucketIds(); + Assertions.assertEquals(List.of(100L, 150L, 200L), bucketIds); + } + + @Test + void register_duplicateMessageId_inSameBucket_doesNotIncrementCounterTwice() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(50L)); + Assertions.assertEquals(1, manager.size()); + + manager.register(100L, newMessage(50L)); + Assertions.assertEquals(1, manager.size()); + + manager.register(100L, newMessage(51L)); + Assertions.assertEquals(2, manager.size()); + } + + @Test + void getNext_whenBucketExists_returnsLowestMessageId_andDoesNotRemoveIt() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(9L)); + manager.register(100L, newMessage(7L)); + manager.register(100L, newMessage(8L)); + + long next1 = manager.getNext(100L); + long next2 = manager.getNext(100L); + + Assertions.assertEquals(7L, next1); + Assertions.assertEquals(7L, next2); + Assertions.assertEquals(3, manager.size()); + } + + @Test + void remove_existingMessage_decrementsCounter_andReturnsTrue() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(7L)); + manager.register(100L, newMessage(8L)); + Assertions.assertEquals(2, manager.size()); + + boolean removed = manager.remove(100L, 7L); + + Assertions.assertTrue(removed); + Assertions.assertEquals(1, manager.size()); + Assertions.assertEquals(8L, manager.getNext(100L)); + } + + @Test + void remove_missingMessage_returnsFalse_andDoesNotChangeCounter() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(7L)); + Assertions.assertEquals(1, manager.size()); + + boolean removed = manager.remove(100L, 999L); + + Assertions.assertFalse(removed); + Assertions.assertEquals(1, manager.size()); + } + + @Test + void getNext_unknownBucket_cleansBucketListEntry_andReturnsMinusOne() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(1L)); + manager.register(200L, newMessage(2L)); + Assertions.assertEquals(List.of(100L, 200L), manager.getBucketIds()); + + // Force the "unknown bucket" path by deleting the bucket from the tree but leaving bucketList behind. + // This is easiest by calling delete(bucketId), which currently removes only from treeList. + boolean deleted = manager.delete(200L); + Assertions.assertTrue(deleted); + + long next = manager.getNext(200L); + Assertions.assertEquals(-1L, next); + + Assertions.assertEquals(List.of(100L), manager.getBucketIds()); + } + + @Test + void delete_bucket_removesBucket_andDecrementsCounter() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(10L)); + manager.register(100L, newMessage(11L)); + manager.register(200L, newMessage(20L)); + Assertions.assertEquals(3, manager.size()); + + boolean deleted = manager.delete(100L); + + Assertions.assertTrue(deleted); + Assertions.assertEquals(1, manager.size()); + Assertions.assertEquals(List.of(200L), manager.getBucketIds()); + } + + @Test + void removeBucket_returnsQueueContainingMessages_andRemovesBucketFromTree() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(10L)); + manager.register(100L, newMessage(12L)); + manager.register(100L, newMessage(11L)); + Assertions.assertEquals(3, manager.size()); + + Queue removed = manager.removeBucket(100L); + + Assertions.assertNotNull(removed); + Assertions.assertEquals(10L, removed.poll()); + Assertions.assertEquals(11L, removed.poll()); + Assertions.assertEquals(12L, removed.poll()); + Assertions.assertNull(removed.poll()); + + // Counter is not adjusted by removeBucket(). That’s a contract decision. + // If you expect size() to change here, this test should enforce it (and code must change). + Assertions.assertEquals(3, manager.size()); + + Assertions.assertEquals(-1L, manager.getNext(100L)); + } + + @Test + void close_clearsAllState_andResetsCounter() throws IOException { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(10L)); + manager.register(200L, newMessage(20L)); + Assertions.assertEquals(2, manager.size()); + Assertions.assertFalse(manager.getBucketIds().isEmpty()); + + manager.close(); + + Assertions.assertEquals(0, manager.size()); + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertTrue(manager.getBucketIds().isEmpty()); + } + + @Test + void delete_clearsAllState_andResetsCounter() throws IOException { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(10L)); + manager.register(200L, newMessage(20L)); + Assertions.assertEquals(2, manager.size()); + + manager.delete(); + + Assertions.assertEquals(0, manager.size()); + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertTrue(manager.getBucketIds().isEmpty()); + } + + @Test + void getNext_whenBucketBecomesEmpty_removesThatBucketId_notTheFirstBucket() { + BitSetFactory factory = new BitSetFactoryImpl(8192); + MessageManager manager = new MessageManager(factory); + + manager.register(100L, newMessage(1L)); + manager.register(200L, newMessage(2L)); + Assertions.assertEquals(List.of(100L, 200L), manager.getBucketIds()); + + // Empty bucket 200 + boolean removed = manager.remove(200L, 2L); + Assertions.assertTrue(removed); + + long next = manager.getNext(200L); + Assertions.assertEquals(-1L, next); + + // Intended behaviour: bucket 200 removed, bucket 100 remains. + Assertions.assertEquals(List.of(100L), manager.getBucketIds()); + } + + private Message newMessage(long id) { + Message message = mock(Message.class); + when(message.getIdentifier()).thenReturn(id); + return message; + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/DestinationSubscriptionManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/DestinationSubscriptionManagerTest.java new file mode 100644 index 000000000..bd7c31e61 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/DestinationSubscriptionManagerTest.java @@ -0,0 +1,359 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.dto.rest.session.SubscriptionStateDTO; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +class DestinationSubscriptionManagerTest { + + @Test + void emptyManager_basics() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + Assertions.assertEquals("dest", manager.getName()); + Assertions.assertFalse(manager.hasSubscriptions()); + + Assertions.assertFalse(manager.hasInterest(1L)); + Assertions.assertFalse(manager.hasMessage(1L)); + Assertions.assertFalse(manager.expired(1L)); + + Assertions.assertTrue(manager.getAll().isEmpty()); + Assertions.assertTrue(manager.getAllAtRest().isEmpty()); + Assertions.assertTrue(manager.getSubscriptionStates().isEmpty()); + + Assertions.assertEquals(0, manager.size()); + Assertions.assertNull(manager.getSubscription("missing")); + } + + @Test + void put_isIdempotentPerKey_doesNotReplaceExisting() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable first = new FakeSubscribable("sub1", "sess1"); + FakeSubscribable second = new FakeSubscribable("sub2", "sess2"); + + manager.put("key", first); + manager.put("key", second); + + Assertions.assertSame(first, manager.getSubscription("key")); + } + + @Test + void remove_returnsInstanceAndRemoves() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub = new FakeSubscribable("sub", "sess"); + manager.put("key", sub); + + Subscribable removed = manager.remove("key"); + Assertions.assertSame(sub, removed); + Assertions.assertNull(manager.getSubscription("key")); + Assertions.assertFalse(manager.hasSubscriptions()); + } + + @Test + void clear_removesAllSubscriptions() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + manager.put("k1", new FakeSubscribable("s1", "a")); + manager.put("k2", new FakeSubscribable("s2", "b")); + Assertions.assertTrue(manager.hasSubscriptions()); + + manager.clear(); + + Assertions.assertFalse(manager.hasSubscriptions()); + Assertions.assertEquals(0, manager.size()); + } + + @Test + void hasInterest_trueIfAnySubscribableHasMessage() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a").withInterest(Set.of(1L, 2L)); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b").withInterest(Set.of(10L)); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + Assertions.assertTrue(manager.hasInterest(2L)); + Assertions.assertTrue(manager.hasInterest(10L)); + Assertions.assertFalse(manager.hasInterest(3L)); + } + + @Test + void scanForInterest_removesIdsThatStillHaveInterestAnywhere() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + manager.put("k1", new FakeSubscribable("s1", "a").withAll(Set.of(1L, 2L))); + manager.put("k2", new FakeSubscribable("s2", "b").withAll(Set.of(4L))); + + Queue removedQueue = new ArrayDeque<>(List.of(1L, 2L, 3L, 4L, 5L)); + Queue result = manager.scanForInterest(removedQueue); + + Assertions.assertEquals(List.of(3L, 5L), new ArrayList<>(result)); + } + + @Test + void register_fansOutAndSumsNonZeroValues() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a").withRegisterResult(1); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b").withRegisterResult(0); + FakeSubscribable sub3 = new FakeSubscribable("s3", "c").withRegisterResult(2); + + manager.put("k1", sub1); + manager.put("k2", sub2); + manager.put("k3", sub3); + + int sum = manager.register((Message) null); + Assertions.assertEquals(3, sum); + + Assertions.assertEquals(1, sub1.registerCalls.get()); + Assertions.assertEquals(1, sub2.registerCalls.get()); + Assertions.assertEquals(1, sub3.registerCalls.get()); + } + + @Test + void expired_returnsTrueIfAnySubscribableReturnsTrue() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a").withExpiredResult(false); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b").withExpiredResult(true); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + Assertions.assertTrue(manager.expired(999L)); + } + + @Test + void pauseAndResume_fanOutToAllSubscribables() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a"); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b"); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + manager.pause(); + Assertions.assertEquals(1, sub1.pauseCalls.get()); + Assertions.assertEquals(1, sub2.pauseCalls.get()); + + manager.resume(); + Assertions.assertEquals(1, sub1.resumeCalls.get()); + Assertions.assertEquals(1, sub2.resumeCalls.get()); + } + + @Test + void close_closesAllSubscribables() throws IOException { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a"); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b"); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + manager.close(); + + Assertions.assertEquals(1, sub1.closeCalls.get()); + Assertions.assertEquals(1, sub2.closeCalls.get()); + } + + @Test + void getAll_returnsUnionOfAllSubscriptionQueues() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + manager.put("k1", new FakeSubscribable("s1", "a").withAll(Set.of(1L, 2L))); + manager.put("k2", new FakeSubscribable("s2", "b").withAll(Set.of(2L, 3L))); + + List all = new ArrayList<>(manager.getAll()); + + // NaturalOrderedLongQueue should keep unique ordered ids + Assertions.assertEquals(List.of(1L, 2L, 3L), all); + } + + @Test + void getSubscriptionStates_includesOnlyNonNullStates() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + SubscriptionStateDTO state1 = new SubscriptionStateDTO(); + FakeSubscribable sub1 = new FakeSubscribable("s1", "a").withState(state1); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b").withState(null); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + List states = manager.getSubscriptionStates(); + Assertions.assertEquals(1, states.size()); + Assertions.assertSame(state1, states.get(0)); + } + + @Test + void size_includesMapSizePlusChildSizes() { + DestinationSubscriptionManager manager = new DestinationSubscriptionManager("dest"); + + FakeSubscribable sub1 = new FakeSubscribable("s1", "a").withSize(5); + FakeSubscribable sub2 = new FakeSubscribable("s2", "b").withSize(7); + + manager.put("k1", sub1); + manager.put("k2", sub2); + + // subscriptions.size() = 2, plus 5 + 7 + Assertions.assertEquals(14, manager.size()); + } + + // ----------------------------------------------------------------------- + // Fake Subscribable + // ----------------------------------------------------------------------- + private static final class FakeSubscribable implements Subscribable { + + private final String name; + private final String sessionId; + + private final Set interest = new HashSet<>(); + private final Set all = new HashSet<>(); + + private volatile int registerResult; + private volatile boolean expiredResult; + private volatile int size; + private volatile SubscriptionStateDTO state; + + private final AtomicInteger registerCalls = new AtomicInteger(); + private final AtomicInteger pauseCalls = new AtomicInteger(); + private final AtomicInteger resumeCalls = new AtomicInteger(); + private final AtomicInteger closeCalls = new AtomicInteger(); + + FakeSubscribable(String name, String sessionId) { + this.name = name; + this.sessionId = sessionId; + this.size = 0; + this.registerResult = 0; + this.expiredResult = false; + this.state = null; + } + + FakeSubscribable withInterest(Set values) { + this.interest.clear(); + this.interest.addAll(values); + return this; + } + + FakeSubscribable withAll(Set values) { + this.all.clear(); + this.all.addAll(values); + return this; + } + + FakeSubscribable withRegisterResult(int value) { + this.registerResult = value; + return this; + } + + FakeSubscribable withExpiredResult(boolean value) { + this.expiredResult = value; + return this; + } + + FakeSubscribable withSize(int value) { + this.size = value; + return this; + } + + FakeSubscribable withState(SubscriptionStateDTO value) { + this.state = value; + return this; + } + + @Override + public int register(Message messageIdentifier) { + registerCalls.incrementAndGet(); + return registerResult; + } + + @Override + public int register(long messageId) { + return 0; + } + + @Override + public boolean hasMessage(long messageIdentifier) { + return interest.contains(messageIdentifier); + } + + @Override + public boolean expired(long messageIdentifier) { + return expiredResult; + } + + @Override + public int size() { + return size; + } + + @Override + public String getName() { + return name; + } + + @Override + public Queue getAll() { + return new ArrayDeque<>(all); + } + + @Override + public Queue getAllAtRest() { + return new ArrayDeque<>(); + } + + @Override + public void pause() { + pauseCalls.incrementAndGet(); + } + + @Override + public void resume() { + resumeCalls.incrementAndGet(); + } + + @Override + public SubscriptionStateDTO getState() { + return state; + } + + @Override + public String getSessionId() { + return sessionId; + } + + @Override + public void close() { + closeCalls.incrementAndGet(); + } + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionBuilderTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionBuilderTest.java new file mode 100644 index 000000000..85da45076 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionBuilderTest.java @@ -0,0 +1,236 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription; + +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.CreditHandler; +import io.mapsmessaging.engine.destination.DestinationImpl; +import io.mapsmessaging.engine.destination.subscription.transaction.AcknowledgementController; +import io.mapsmessaging.engine.destination.subscription.transaction.AutoAcknowledgementController; +import io.mapsmessaging.engine.destination.subscription.transaction.ClientAcknowledgementController; +import io.mapsmessaging.engine.destination.subscription.transaction.ClientCreditManager; +import io.mapsmessaging.engine.destination.subscription.transaction.CreditManager; +import io.mapsmessaging.engine.destination.subscription.transaction.FixedCreditManager; +import io.mapsmessaging.engine.destination.subscription.transaction.IndividualAcknowledgementController; +import io.mapsmessaging.engine.session.SessionImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; + +class SubscriptionBuilderTest { + + @Test + void compileParser_nullOrEmptySelector_resultsInNullParserExecutor() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext nullSelectorContext = Mockito.mock(SubscriptionContext.class); + Mockito.when(nullSelectorContext.getSelector()).thenReturn(null); + + SubscriptionContext emptySelectorContext = Mockito.mock(SubscriptionContext.class); + Mockito.when(emptySelectorContext.getSelector()).thenReturn(""); + + TestSubscriptionBuilder builderNull = new TestSubscriptionBuilder(destination, nullSelectorContext); + Assertions.assertNull(builderNull.getParserExecutor()); + + TestSubscriptionBuilder builderEmpty = new TestSubscriptionBuilder(destination, emptySelectorContext); + Assertions.assertNull(builderEmpty.getParserExecutor()); + } + + @Test + void compileParser_invalidSelector_throwsIOException() { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext badSelectorContext = Mockito.mock(SubscriptionContext.class); + Mockito.when(badSelectorContext.getSelector()).thenReturn("and and and"); // deliberately garbage + + IOException exception = Assertions.assertThrows(IOException.class, () -> new TestSubscriptionBuilder(destination, badSelectorContext)); + Assertions.assertNotNull(exception.getMessage()); + } + + @Test + void combineSelectors_bothNullOrEmpty_returnsEmptyString() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext child = Mockito.mock(SubscriptionContext.class); + Mockito.when(child.getSelector()).thenReturn(null); + + SubscriptionContext parent = Mockito.mock(SubscriptionContext.class); + Mockito.when(parent.getSelector()).thenReturn(""); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, child, parent); + + String combined = builder.combineSelectorsForTest(null, ""); + Assertions.assertEquals("", combined); + } + + @Test + void combineSelectors_onlyLeft_present_returnsLeft() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + SubscriptionContext child = Mockito.mock(SubscriptionContext.class); + Mockito.when(child.getSelector()).thenReturn("a = 1"); + + SubscriptionContext parent = Mockito.mock(SubscriptionContext.class); + Mockito.when(parent.getSelector()).thenReturn(""); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, child, parent); + + String combined = builder.combineSelectorsForTest("a = 1", ""); + Assertions.assertEquals("a = 1", combined.trim()); + } + + @Test + void combineSelectors_onlyRight_present_returnsRight() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + SubscriptionContext child = Mockito.mock(SubscriptionContext.class); + Mockito.when(child.getSelector()).thenReturn(""); + + SubscriptionContext parent = Mockito.mock(SubscriptionContext.class); + Mockito.when(parent.getSelector()).thenReturn("b = 2"); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, child, parent); + + String combined = builder.combineSelectorsForTest("", "b = 2"); + Assertions.assertEquals("b = 2", combined); + } + + @Test + void combineSelectors_bothPresent_joinsWithAnd() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + SubscriptionContext child = Mockito.mock(SubscriptionContext.class); + Mockito.when(child.getSelector()).thenReturn("a = 1"); + + SubscriptionContext parent = Mockito.mock(SubscriptionContext.class); + Mockito.when(parent.getSelector()).thenReturn("b = 2"); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, child, parent); + + String combined = builder.combineSelectorsForTest("a = 1", "b = 2"); + Assertions.assertEquals("a = 1 and b = 2", combined); + } + + @Test + void createCreditManager_client_returnsClientCreditManager() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.getSelector()).thenReturn(null); + Mockito.when(context.getReceiveMaximum()).thenReturn(123); + Mockito.when(context.getCreditHandler()).thenReturn(CreditHandler.CLIENT); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, context); + + CreditManager creditManager = builder.createCreditManagerForTest(context); + Assertions.assertInstanceOf(ClientCreditManager.class, creditManager); + } + + @Test + void createCreditManager_auto_returnsFixedCreditManager() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.getSelector()).thenReturn(null); + Mockito.when(context.getReceiveMaximum()).thenReturn(456); + Mockito.when(context.getCreditHandler()).thenReturn(CreditHandler.AUTO); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, context); + + CreditManager creditManager = builder.createCreditManagerForTest(context); + Assertions.assertInstanceOf(FixedCreditManager.class, creditManager); + } + + @Test + void createAcknowledgementController_individual_returnsIndividualController() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.getSelector()).thenReturn(null); + Mockito.when(context.getReceiveMaximum()).thenReturn(10); + Mockito.when(context.getCreditHandler()).thenReturn(CreditHandler.AUTO); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, context); + + AcknowledgementController controller = builder.createAcknowledgementControllerForTest(ClientAcknowledgement.INDIVIDUAL); + Assertions.assertInstanceOf(IndividualAcknowledgementController.class, controller); + } + + @Test + void createAcknowledgementController_block_returnsClientAckController() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.getSelector()).thenReturn(null); + Mockito.when(context.getReceiveMaximum()).thenReturn(10); + Mockito.when(context.getCreditHandler()).thenReturn(CreditHandler.AUTO); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, context); + + AcknowledgementController controller = builder.createAcknowledgementControllerForTest(ClientAcknowledgement.BLOCK); + Assertions.assertInstanceOf(ClientAcknowledgementController.class, controller); + } + + @Test + void createAcknowledgementController_auto_returnsAutoController() throws IOException { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.getSelector()).thenReturn(null); + Mockito.when(context.getReceiveMaximum()).thenReturn(10); + Mockito.when(context.getCreditHandler()).thenReturn(CreditHandler.AUTO); + + TestSubscriptionBuilder builder = new TestSubscriptionBuilder(destination, context); + + AcknowledgementController controller = builder.createAcknowledgementControllerForTest(ClientAcknowledgement.AUTO); + Assertions.assertInstanceOf(AutoAcknowledgementController.class, controller); + } + + private static final class TestSubscriptionBuilder extends SubscriptionBuilder { + + TestSubscriptionBuilder(DestinationImpl destination, SubscriptionContext context) throws IOException { + super(destination, context); + } + + TestSubscriptionBuilder(DestinationImpl destination, SubscriptionContext context, SubscriptionContext parent) throws IOException { + super(destination, context, parent); + } + + @Override + public Subscription construct(SessionImpl session, String sessionId, String uniqueSessionId, long sessionUniqueId) { + throw new UnsupportedOperationException("Not required for builder unit tests."); + } + + Object getParserExecutor() { + return parserExecutor; + } + + String combineSelectorsForTest(String lhs, String rhs) { + return super.combineSelectors(lhs, rhs); + } + + CreditManager createCreditManagerForTest(SubscriptionContext context) { + return super.createCreditManager(context); + } + + AcknowledgementController createAcknowledgementControllerForTest(ClientAcknowledgement ack) { + return super.createAcknowledgementController(ack); + } + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionContextTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionContextTest.java new file mode 100644 index 000000000..06e6672e7 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/SubscriptionContextTest.java @@ -0,0 +1,306 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription; + +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.CreditHandler; +import io.mapsmessaging.api.features.DestinationMode; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.features.RetainHandler; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.util.BitSet; + +class SubscriptionContextTest { + + @Test + void defaults_constructor_setsExpectedValues() { + SubscriptionContext context = new SubscriptionContext("a/b"); + + Assertions.assertEquals(-1L, context.getAllocatedId()); + Assertions.assertEquals("a/b", context.getDestinationName()); + Assertions.assertEquals("a/b", context.getAlias()); + Assertions.assertEquals(0L, context.getSubscriptionId()); + Assertions.assertEquals(0, context.getMaxAtRest()); + Assertions.assertEquals(1, context.getReceiveMaximum()); + + Assertions.assertEquals(CreditHandler.AUTO, context.getCreditHandler()); + Assertions.assertEquals(RetainHandler.SEND_ALWAYS, context.getRetainHandler()); + Assertions.assertEquals(QualityOfService.AT_MOST_ONCE, context.getQualityOfService()); + Assertions.assertEquals(ClientAcknowledgement.AUTO, context.getAcknowledgementController()); + + Assertions.assertNotNull(context.getFlags()); + Assertions.assertFalse(context.noLocalMessages()); + Assertions.assertFalse(context.allowOverlap()); + Assertions.assertFalse(context.isBrowser()); + Assertions.assertFalse(context.isRetainAsPublish()); + Assertions.assertFalse(context.isSync()); + + Assertions.assertEquals(DestinationMode.NORMAL, context.getDestinationMode()); + } + + @Test + void schemaNamespace_prefix_isParsed_andRemovedFromDestinationName() { + String schemaPrefix = DestinationMode.SCHEMA.getNamespace(); + SubscriptionContext context = new SubscriptionContext(schemaPrefix + "sensor/temp"); + + Assertions.assertEquals(DestinationMode.SCHEMA, context.getDestinationMode()); + Assertions.assertEquals("sensor/temp", context.getDestinationName()); + + String key = context.getKey(); + Assertions.assertTrue(key.startsWith(DestinationMode.SCHEMA.getNamespace())); + Assertions.assertTrue(key.contains("sensor/temp")); + } + + @Test + void setDestinationName_updatesAliasOnlyWhenAliasMatchedOldDestination() { + SubscriptionContext context = new SubscriptionContext("old"); + + // alias initially equals destinationName, so rename should update alias too + context.setDestinationName("new"); + Assertions.assertEquals("new", context.getDestinationName()); + Assertions.assertEquals("new", context.getAlias()); + + // now set custom alias, rename should NOT overwrite it + context.setAlias("custom"); + context.setDestinationName("newer"); + Assertions.assertEquals("newer", context.getDestinationName()); + Assertions.assertEquals("custom", context.getAlias()); + } + + @Test + void setAlias_null_fallsBackToCorrectedPath() throws Exception { + SubscriptionContext context = new SubscriptionContext("dest"); + + // rootPath has no setter, so we set it via reflection to test behavior. + setPrivateField(context, "rootPath", "/root//"); + context.setDestinationName("/a//b"); + + context.setAlias(null); + Assertions.assertEquals("/root/a/b", context.getAlias().replace("//", "/")); + } + + @Test + void wildcardDetection_works() { + SubscriptionContext normal = new SubscriptionContext("a/b"); + Assertions.assertFalse(normal.containsWildcard()); + + SubscriptionContext hash = new SubscriptionContext("a/#"); + Assertions.assertTrue(hash.containsWildcard()); + + SubscriptionContext plus = new SubscriptionContext("a/+/b"); + Assertions.assertTrue(plus.containsWildcard()); + } + + @Test + void sharedSubscriptionDetection_works() { + SubscriptionContext context = new SubscriptionContext("a/b"); + Assertions.assertFalse(context.isSharedSubscription()); + + context.setSharedName(""); + Assertions.assertFalse(context.isSharedSubscription()); + + context.setSharedName("group1"); + Assertions.assertTrue(context.isSharedSubscription()); + } + + @Test + void flags_roundTripThroughSettersAndGetters() { + SubscriptionContext context = new SubscriptionContext("a/b"); + + context.setNoLocalMessages(true); + context.setAllowOverlap(true); + context.setBrowserFlag(true); + context.setRetainAsPublish(true); + context.setSync(true); + + Assertions.assertTrue(context.noLocalMessages()); + Assertions.assertTrue(context.allowOverlap()); + Assertions.assertTrue(context.isBrowser()); + Assertions.assertTrue(context.isRetainAsPublish()); + Assertions.assertTrue(context.isSync()); + + context.setNoLocalMessages(false); + context.setAllowOverlap(false); + context.setBrowserFlag(false); + context.setRetainAsPublish(false); + context.setSync(false); + + Assertions.assertFalse(context.noLocalMessages()); + Assertions.assertFalse(context.allowOverlap()); + Assertions.assertFalse(context.isBrowser()); + Assertions.assertFalse(context.isRetainAsPublish()); + Assertions.assertFalse(context.isSync()); + } + + @Test + void saveLoad_roundTrip_preservesState() throws Exception { + SubscriptionContext original = new SubscriptionContext("topic/test"); + + // rootPath no setter: set via reflection + setPrivateField(original, "rootPath", "/root/"); + original.setAlias("alias1"); + original.setSharedName("share"); + original.setSelector("a = 1"); + + original.setAcknowledgementController(ClientAcknowledgement.AUTO); + original.setCreditHandler(CreditHandler.AUTO); + original.setRetainHandler(RetainHandler.SEND_ALWAYS); + original.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + + original.setSubscriptionId(1234L); + original.setReceiveMaximum(77); + original.setMaxAtRest(999); + + original.setNoLocalMessages(true); + original.setAllowOverlap(true); + original.setBrowserFlag(false); + original.setRetainAsPublish(true); + original.setSync(true); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + original.save(baos); + + long sessionId = 555L; + SubscriptionContext loaded = new SubscriptionContext(new ByteArrayInputStream(baos.toByteArray()), sessionId); + + Assertions.assertEquals(sessionId, loaded.getAllocatedId()); + Assertions.assertEquals(original.getDestinationName(), loaded.getDestinationName()); + Assertions.assertEquals(original.getAlias(), loaded.getAlias()); + Assertions.assertEquals(original.getSharedName(), loaded.getSharedName()); + Assertions.assertEquals(original.getSelector(), loaded.getSelector()); + + Assertions.assertEquals(original.getAcknowledgementController(), loaded.getAcknowledgementController()); + Assertions.assertEquals(original.getDestinationMode(), loaded.getDestinationMode()); + Assertions.assertEquals(original.getRetainHandler(), loaded.getRetainHandler()); + Assertions.assertEquals(original.getQualityOfService(), loaded.getQualityOfService()); + Assertions.assertEquals(original.getCreditHandler(), loaded.getCreditHandler()); + + Assertions.assertEquals(original.getSubscriptionId(), loaded.getSubscriptionId()); + Assertions.assertEquals(original.getReceiveMaximum(), loaded.getReceiveMaximum()); + Assertions.assertEquals(original.getMaxAtRest(), loaded.getMaxAtRest()); + + Assertions.assertEquals(original.noLocalMessages(), loaded.noLocalMessages()); + Assertions.assertEquals(original.allowOverlap(), loaded.allowOverlap()); + Assertions.assertEquals(original.isBrowser(), loaded.isBrowser()); + Assertions.assertEquals(original.isRetainAsPublish(), loaded.isRetainAsPublish()); + Assertions.assertEquals(original.isSync(), loaded.isSync()); + + // Root path should round-trip too (it is persisted) + Assertions.assertEquals(getPrivateField(original, "rootPath"), getPrivateField(loaded, "rootPath")); + } + + @Test + void compareTo_ordersByQualityOfServiceDescending() { + SubscriptionContext qos0 = new SubscriptionContext("a"); + qos0.setQualityOfService(QualityOfService.AT_MOST_ONCE); + + SubscriptionContext qos1 = new SubscriptionContext("b"); + qos1.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + + SubscriptionContext qos2 = new SubscriptionContext("c"); + qos2.setQualityOfService(QualityOfService.EXACTLY_ONCE); + + // compareTo returns: lhs.level - this.level + // So higher QoS should be considered "smaller" (comes first in ascending sorts). + + Assertions.assertTrue(qos2.compareTo(qos1) < 0); + Assertions.assertTrue(qos2.compareTo(qos0) < 0); + + Assertions.assertTrue(qos1.compareTo(qos2) > 0); + Assertions.assertTrue(qos1.compareTo(qos0) < 0); + + Assertions.assertTrue(qos0.compareTo(qos2) > 0); + Assertions.assertTrue(qos0.compareTo(qos1) > 0); + + Assertions.assertEquals(0, qos1.compareTo(qos1)); + } + + @Test + void equals_isQualityOfServiceOnly_andIgnoresEverythingElse() throws Exception { + SubscriptionContext a = new SubscriptionContext("one"); + SubscriptionContext b = new SubscriptionContext("two"); + + a.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + b.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + + // Make everything else wildly different + a.setAlias("alias-a"); + b.setAlias("alias-b"); + a.setSharedName("share-a"); + b.setSharedName("share-b"); + a.setSelector("x = 1"); + b.setSelector("y = 2"); + a.setSubscriptionId(1L); + b.setSubscriptionId(2L); + a.setReceiveMaximum(10); + b.setReceiveMaximum(99); + a.setMaxAtRest(1); + b.setMaxAtRest(999); + + BitSet flagsA = new BitSet(); + flagsA.set(0, true); + a.setFlags(flagsA); + + BitSet flagsB = new BitSet(); + flagsB.set(3, true); + b.setFlags(flagsB); + + setPrivateField(a, "rootPath", "/a/"); + setPrivateField(b, "rootPath", "/b/"); + + Assertions.assertEquals(a, b); + + b.setQualityOfService(QualityOfService.AT_MOST_ONCE); + Assertions.assertNotEquals(a, b); + } + + @Test + void equalsHashCode_contractRisk_isVisible() { + SubscriptionContext a = new SubscriptionContext("x"); + SubscriptionContext b = new SubscriptionContext("y"); + a.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + b.setQualityOfService(QualityOfService.AT_LEAST_ONCE); + + // equals true... + Assertions.assertEquals(a, b); + + // ...but hashCode is inherited (and likely differs). This isn't "assert must differ", + // it's "this can differ", so we just assert the current behavior isn't forcing equality. + // If this starts passing with equal hashCodes later, fine. The real issue is equals ignores identity. + Assertions.assertNotEquals(a.hashCode(), b.hashCode()); + } + + private static void setPrivateField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getPrivateField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionManagerTest.java new file mode 100644 index 000000000..fb09946d0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionManagerTest.java @@ -0,0 +1,156 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.impl.shared; + +import io.mapsmessaging.dto.rest.session.SubscriptionStateDTO; +import io.mapsmessaging.engine.destination.subscription.state.BoundedMessageStateManager; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +class SharedSubscriptionManagerTest { + + @Test + void newInstance_isEmpty_andExposesName_andStateManager() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + + Assertions.assertEquals("group-A", manager.getName()); + Assertions.assertTrue(manager.isEmpty()); + + BoundedMessageStateManager stateManager = manager.getMessageStateManager(); + Assertions.assertNotNull(stateManager); + Assertions.assertSame(stateManager, manager.getMessageStateManager()); + + Assertions.assertNull(manager.find("missing")); + } + + @Test + void add_thenFind_returnsSameInstance_andIsNotEmpty() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription = Mockito.mock(SharedSubscription.class); + + manager.add("sub-1", subscription); + + Assertions.assertFalse(manager.isEmpty()); + Assertions.assertSame(subscription, manager.find("sub-1")); + Assertions.assertNull(manager.find("sub-2")); + } + + @Test + void delete_removesAndReturnsSubscription() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription = Mockito.mock(SharedSubscription.class); + + manager.add("sub-1", subscription); + + SharedSubscription removed = manager.delete("sub-1"); + + Assertions.assertSame(subscription, removed); + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertNull(manager.find("sub-1")); + } + + @Test + void delete_missingKey_returnsNull_andDoesNotChangeState() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + + SharedSubscription removed = manager.delete("nope"); + + Assertions.assertNull(removed); + Assertions.assertTrue(manager.isEmpty()); + } + + @Test + void close_callsCloseOnAllActiveSubscriptions() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription1 = Mockito.mock(SharedSubscription.class); + SharedSubscription subscription2 = Mockito.mock(SharedSubscription.class); + + manager.add("sub-1", subscription1); + manager.add("sub-2", subscription2); + + manager.close(); + + Mockito.verify(subscription1).close(); + Mockito.verify(subscription2).close(); + Mockito.verifyNoMoreInteractions(subscription1, subscription2); + } + + @Test + void complete_callsExpiredOnAllActiveSubscriptions() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription1 = Mockito.mock(SharedSubscription.class); + SharedSubscription subscription2 = Mockito.mock(SharedSubscription.class); + + manager.add("sub-1", subscription1); + manager.add("sub-2", subscription2); + + manager.complete(123L); + + Mockito.verify(subscription1).expired(123L); + Mockito.verify(subscription2).expired(123L); + Mockito.verifyNoMoreInteractions(subscription1, subscription2); + } + + @Test + void getSubscriptionStates_returnsAllStates_inInsertionOrder() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription1 = Mockito.mock(SharedSubscription.class); + SharedSubscription subscription2 = Mockito.mock(SharedSubscription.class); + + SubscriptionStateDTO state1 = new SubscriptionStateDTO(); + SubscriptionStateDTO state2 = new SubscriptionStateDTO(); + + Mockito.when(subscription1.getState()).thenReturn(state1); + Mockito.when(subscription2.getState()).thenReturn(state2); + + manager.add("sub-1", subscription1); + manager.add("sub-2", subscription2); + + List states = manager.getSubscriptionStates(); + + Assertions.assertEquals(2, states.size()); + Assertions.assertSame(state1, states.get(0)); + Assertions.assertSame(state2, states.get(1)); + } + + @Test + void getSubscriptionStates_includesNullStates_currentBehavior() { + SharedSubscriptionManager manager = new SharedSubscriptionManager("group-A"); + SharedSubscription subscription1 = Mockito.mock(SharedSubscription.class); + SharedSubscription subscription2 = Mockito.mock(SharedSubscription.class); + + SubscriptionStateDTO state2 = new SubscriptionStateDTO(); + + Mockito.when(subscription1.getState()).thenReturn(null); + Mockito.when(subscription2.getState()).thenReturn(state2); + + manager.add("sub-1", subscription1); + manager.add("sub-2", subscription2); + + List states = manager.getSubscriptionStates(); + + Assertions.assertEquals(2, states.size()); + Assertions.assertNull(states.get(0)); + Assertions.assertSame(state2, states.get(1)); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionRegisterTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionRegisterTest.java new file mode 100644 index 000000000..bf417310a --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/impl/shared/SharedSubscriptionRegisterTest.java @@ -0,0 +1,175 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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. + */ + +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.impl.shared; + +import io.mapsmessaging.dto.rest.session.SubscriptionStateDTO; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +class SharedSubscriptionRegisterTest { + + @Test + void newRegister_getUnknown_returnsNull() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + Assertions.assertNull(register.get("missing")); + } + + @Test + void add_thenGet_returnsSameInstance() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + FakeSharedSubscriptionManager manager = new FakeSharedSubscriptionManager(); + + register.add("groupA", manager); + + Assertions.assertSame(manager, register.get("groupA")); + } + + @Test + void add_existingKey_returnsPrevious_andReplaces() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + FakeSharedSubscriptionManager first = new FakeSharedSubscriptionManager(); + FakeSharedSubscriptionManager second = new FakeSharedSubscriptionManager(); + + Assertions.assertNull(register.add("groupA", first)); + + FakeSharedSubscriptionManager previous = (FakeSharedSubscriptionManager) register.add("groupA", second); + Assertions.assertSame(first, previous); + Assertions.assertSame(second, register.get("groupA")); + } + + @Test + void del_removesEntry() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + FakeSharedSubscriptionManager manager = new FakeSharedSubscriptionManager(); + + register.add("groupA", manager); + Assertions.assertNotNull(register.get("groupA")); + + register.del("groupA"); + Assertions.assertNull(register.get("groupA")); + } + + @Test + void del_unknownKey_isNoop() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + register.del("missing"); // should not throw + Assertions.assertNull(register.get("missing")); + } + + @Test + void getState_emptyRegister_returnsEmptyList() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + List states = register.getState(); + + Assertions.assertNotNull(states); + Assertions.assertTrue(states.isEmpty()); + } + + @Test + void getState_flattensAllManagerStates_inInsertionOrder() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + + SubscriptionStateDTO a1 = new SubscriptionStateDTO(); + SubscriptionStateDTO a2 = new SubscriptionStateDTO(); + SubscriptionStateDTO b1 = new SubscriptionStateDTO(); + + FakeSharedSubscriptionManager managerA = new FakeSharedSubscriptionManager(List.of(a1, a2)); + FakeSharedSubscriptionManager managerB = new FakeSharedSubscriptionManager(List.of(b1)); + + register.add("A", managerA); + register.add("B", managerB); + + List combined = register.getState(); + + Assertions.assertEquals(3, combined.size()); + Assertions.assertSame(a1, combined.get(0)); + Assertions.assertSame(a2, combined.get(1)); + Assertions.assertSame(b1, combined.get(2)); + } + + @Test + void getState_managerReturnsEmptyList_stillWorks() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + + FakeSharedSubscriptionManager emptyManager = new FakeSharedSubscriptionManager(Collections.emptyList()); + register.add("A", emptyManager); + + List combined = register.getState(); + + Assertions.assertNotNull(combined); + Assertions.assertTrue(combined.isEmpty()); + } + + @Test + void getState_managerReturnsNullList_throwsNullPointerException() { + SharedSubscriptionRegister register = new SharedSubscriptionRegister(); + + FakeSharedSubscriptionManager nullManager = new FakeSharedSubscriptionManager(null); + register.add("A", nullManager); + + Assertions.assertThrows(NullPointerException.class, register::getState); + } + + // ----------------------------------------------------------------------- + // Fake SharedSubscriptionManager + // ----------------------------------------------------------------------- + private static final class FakeSharedSubscriptionManager extends SharedSubscriptionManager { + + private final List states; + + private FakeSharedSubscriptionManager() { + super("name"); + this.states = new ArrayList<>(); + } + + private FakeSharedSubscriptionManager(List states) { + super("name"); + this.states = states; + } + + @Override + public List getSubscriptionStates() { + return states; + } + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetTest.java new file mode 100644 index 000000000..aa79c8913 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetTest.java @@ -0,0 +1,183 @@ +package io.mapsmessaging.engine.destination.subscription.set; + +import io.mapsmessaging.engine.destination.DestinationImpl; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +class DestinationSetTest { + + private DestinationImpl destination(String name) { + DestinationImpl d = Mockito.mock(DestinationImpl.class); + Mockito.when(d.getFullyQualifiedNamespace()).thenReturn(name); + return d; + } + + private SubscriptionContext context(String filter, boolean wildcard) { + SubscriptionContext ctx = Mockito.mock(SubscriptionContext.class); + Mockito.when(ctx.getFilter()).thenReturn(filter); + Mockito.when(ctx.containsWildcard()).thenReturn(wildcard); + return ctx; + } + + @Test + void constructor_copiesProvidedMap_notBackedByIt() { + Map source = new LinkedHashMap<>(); + DestinationImpl d1 = destination("a/b"); + source.put("a/b", d1); + + DestinationSet set = new DestinationSet(context("a/#", true), source); + + source.clear(); + + Assertions.assertEquals(1, set.size()); + Assertions.assertTrue(set.contains(d1)); + } + + @Test + void interest_exactMatch_withoutWildcard() { + DestinationSet set = + new DestinationSet(context("a/b", false), Map.of()); + + Assertions.assertTrue(set.interest("a/b")); + Assertions.assertFalse(set.interest("a/c")); + } + + @Test + void interest_wildcardMatch() { + DestinationSet set = + new DestinationSet(context("a/+", true), Map.of()); + + Assertions.assertTrue(set.interest("a/b")); + Assertions.assertFalse(set.interest("a/b/c")); + } + + @Test + void add_onlyAddsWhenMatchingContext() { + DestinationImpl match = destination("a/b"); + DestinationImpl noMatch = destination("x/y"); + + DestinationSet set = + new DestinationSet(context("a/#", true), Map.of()); + + Assertions.assertTrue(set.add(match)); + Assertions.assertFalse(set.add(noMatch)); + + Assertions.assertEquals(1, set.size()); + Assertions.assertTrue(set.contains("a/b")); + } + + @Test + void add_overwritesByNamespaceKey() { + DestinationImpl d1 = destination("a/b"); + DestinationImpl d2 = destination("a/b"); + + DestinationSet set = + new DestinationSet(context("a/#", true), Map.of()); + + set.add(d1); + set.add(d2); + + Assertions.assertEquals(1, set.size()); + Assertions.assertTrue(set.contains(d2)); + } + + @Test + void remove_byStringKey() { + DestinationImpl d = destination("a/b"); + + DestinationSet set = + new DestinationSet(context("a/#", true), Map.of("a/b", d)); + + Assertions.assertTrue(set.remove("a/b")); + Assertions.assertTrue(set.isEmpty()); + } + + @Test + void remove_byDestinationInstance() { + DestinationImpl d = destination("a/b"); + + DestinationSet set = + new DestinationSet(context("a/#", true), Map.of("a/b", d)); + + Assertions.assertTrue(set.remove(d)); + Assertions.assertFalse(set.contains(d)); + } + + @Test + void addAll_addsOnlyMatchingDestinations() { + DestinationImpl d1 = destination("a/1"); + DestinationImpl d2 = destination("a/2"); + DestinationImpl d3 = destination("b/1"); + + DestinationSet set = + new DestinationSet(context("a/#", true), Map.of()); + + boolean changed = set.addAll(List.of(d1, d2, d3)); + + Assertions.assertTrue(changed); + Assertions.assertEquals(2, set.size()); + Assertions.assertFalse(set.contains(d3)); + } + + @Test + void removeAll_removesMatchingEntries() { + DestinationImpl d1 = destination("a/1"); + DestinationImpl d2 = destination("a/2"); + + DestinationSet set = + new DestinationSet( + context("a/#", true), + new LinkedHashMap<>(Map.of( + "a/1", d1, + "a/2", d2 + )) + ); + + boolean changed = set.removeAll(List.of("a/1", d2)); + + Assertions.assertTrue(changed); + Assertions.assertTrue(set.isEmpty()); + } + + @Test + void removeIf_removesFirstMatchingOnly() { + DestinationImpl d1 = destination("a/1"); + DestinationImpl d2 = destination("a/2"); + + DestinationSet set = + new DestinationSet( + context("a/#", true), + new LinkedHashMap<>(Map.of( + "a/1", d1, + "a/2", d2 + )) + ); + + boolean removed = set.removeIf(d -> d.getFullyQualifiedNamespace().endsWith("1")); + + Assertions.assertTrue(removed); + Assertions.assertEquals(1, set.size()); + } + + @Test + void clear_removesAll() { + DestinationSet set = + new DestinationSet( + context("a/#", true), + new LinkedHashMap<>(Map.of( + "a/1", destination("a/1"), + "a/2", destination("a/2") + )) + ); + + set.clear(); + + Assertions.assertTrue(set.isEmpty()); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetUsageTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetUsageTest.java new file mode 100644 index 000000000..3f8fdf1c4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/set/DestinationSetUsageTest.java @@ -0,0 +1,144 @@ +package io.mapsmessaging.engine.destination.subscription.set; + +import io.mapsmessaging.engine.destination.DestinationImpl; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +class DestinationSetUsageTest { + + private DestinationImpl destination(String fullyQualifiedNamespace) { + DestinationImpl destination = Mockito.mock(DestinationImpl.class); + Mockito.when(destination.getFullyQualifiedNamespace()).thenReturn(fullyQualifiedNamespace); + return destination; + } + + private SubscriptionContext wildcardContext(String filter) { + SubscriptionContext context = Mockito.mock(SubscriptionContext.class); + Mockito.when(context.containsWildcard()).thenReturn(true); + Mockito.when(context.getFilter()).thenReturn(filter); + return context; + } + + @Test + void constructedFromInitialWildcardMatches_thenIncrementallyMaintained() { + SubscriptionContext context = wildcardContext("a/#"); + + DestinationImpl a1 = destination("a/1"); + DestinationImpl a2 = destination("a/2"); + + Map initialMatches = new LinkedHashMap<>(); + initialMatches.put("a/1", a1); + initialMatches.put("a/2", a2); + + DestinationSet set = new DestinationSet(context, initialMatches); + + Assertions.assertEquals(2, set.size()); + Assertions.assertTrue(set.contains("a/1")); + Assertions.assertTrue(set.contains("a/2")); + + // Destination created later that matches + DestinationImpl a3 = destination("a/3"); + Assertions.assertTrue(set.add(a3)); + Assertions.assertEquals(3, set.size()); + Assertions.assertTrue(set.contains("a/3")); + + // Destination created later that does NOT match + DestinationImpl b9 = destination("b/9"); + Assertions.assertFalse(set.add(b9)); + Assertions.assertEquals(3, set.size()); + Assertions.assertFalse(set.contains("b/9")); + + // Destination removed later + Assertions.assertTrue(set.remove("a/2")); + Assertions.assertEquals(2, set.size()); + Assertions.assertFalse(set.contains("a/2")); + + Assertions.assertTrue(set.remove(a1)); + Assertions.assertEquals(1, set.size()); + Assertions.assertFalse(set.contains("a/1")); + Assertions.assertTrue(set.contains("a/3")); + } + + @Test + void interest_reportsWouldMatch_evenIfNotPresentInSet() { + SubscriptionContext context = wildcardContext("a/+"); + + DestinationImpl a1 = destination("a/1"); + Map initialMatches = new LinkedHashMap<>(); + initialMatches.put("a/1", a1); + + DestinationSet set = new DestinationSet(context, initialMatches); + + Assertions.assertTrue(set.interest("a/2")); + Assertions.assertFalse(set.interest("a/2/x")); + Assertions.assertFalse(set.contains("a/2")); + } + + @Test + void constructorTrustsInitialMap_evenIfItContainsNonMatchingEntries() { + SubscriptionContext context = wildcardContext("a/+"); + + DestinationImpl a1 = destination("a/1"); + DestinationImpl b1 = destination("b/1"); // does NOT match a/+ + + Map initialMatches = new LinkedHashMap<>(); + initialMatches.put("a/1", a1); + initialMatches.put("b/1", b1); + + DestinationSet set = new DestinationSet(context, initialMatches); + + // Documenting current behaviour: constructor copies, does not filter. + Assertions.assertEquals(2, set.size()); + Assertions.assertTrue(set.contains("a/1")); + Assertions.assertTrue(set.contains("b/1")); + Assertions.assertFalse(set.interest("b/1")); + } + + @Test + void iterationOrder_isInsertionOrder_fromInitialMap_thenAddsAppend() { + SubscriptionContext context = wildcardContext("a/#"); + + DestinationImpl a1 = destination("a/1"); + DestinationImpl a2 = destination("a/2"); + + Map initialMatches = new LinkedHashMap<>(); + initialMatches.put("a/1", a1); + initialMatches.put("a/2", a2); + + DestinationSet set = new DestinationSet(context, initialMatches); + + DestinationImpl a3 = destination("a/3"); + set.add(a3); + + Iterator iterator = set.iterator(); + + Assertions.assertSame(a1, iterator.next()); + Assertions.assertSame(a2, iterator.next()); + Assertions.assertSame(a3, iterator.next()); + Assertions.assertFalse(iterator.hasNext()); + } + + @Test + void add_overwritesSameNamespace_keyedByFullyQualifiedNamespace() { + SubscriptionContext context = wildcardContext("a/#"); + + DestinationImpl first = destination("a/1"); + DestinationImpl replacement = destination("a/1"); + + DestinationSet set = new DestinationSet(context, new LinkedHashMap<>()); + + Assertions.assertTrue(set.add(first)); + Assertions.assertEquals(1, set.size()); + Assertions.assertTrue(set.contains(first)); + + Assertions.assertTrue(set.add(replacement)); + Assertions.assertEquals(1, set.size()); + Assertions.assertTrue(set.contains(replacement)); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/state/BoundedMessageStateManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/BoundedMessageStateManagerTest.java new file mode 100644 index 000000000..b1890b37a --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/BoundedMessageStateManagerTest.java @@ -0,0 +1,284 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.state; + +import io.mapsmessaging.api.message.Message; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; + +class BoundedMessageStateManagerTest { + + @Test + void addAndRemove_manageMembership() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + Assertions.assertTrue(bounded.add(manager1)); + Assertions.assertTrue(bounded.add(manager2)); + + Assertions.assertTrue(bounded.remove(manager1)); + Assertions.assertFalse(bounded.remove(manager1)); + } + + @Test + void hasMessage_returnsTrueWhenAnyUnderlyingHasIt() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.hasMessage(123L)).thenReturn(false); + Mockito.when(manager2.hasMessage(123L)).thenReturn(true); + + Assertions.assertTrue(bounded.hasMessage(123L)); + } + + @Test + void hasMessage_returnsFalseWhenNoneHaveIt() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.hasMessage(123L)).thenReturn(false); + Mockito.when(manager2.hasMessage(123L)).thenReturn(false); + + Assertions.assertFalse(bounded.hasMessage(123L)); + } + + @Test + void expired_delegatesToAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + bounded.expired(999L); + + Mockito.verify(manager1).expired(999L); + Mockito.verify(manager2).expired(999L); + } + + @Test + void allocate_delegatesToAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Message message = Mockito.mock(Message.class); + + bounded.allocate(message); + + Mockito.verify(manager1).allocate(message); + Mockito.verify(manager2).allocate(message); + } + + @Test + void commit_delegatesToAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + bounded.commit(77L); + + Mockito.verify(manager1).commit(77L); + Mockito.verify(manager2).commit(77L); + } + + @Test + void rollback_returnsTrueWhenAnyRollbackSucceeds_andCallsAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.rollback(42L)).thenReturn(false); + Mockito.when(manager2.rollback(42L)).thenReturn(true); + + Assertions.assertTrue(bounded.rollback(42L)); + + Mockito.verify(manager1).rollback(42L); + Mockito.verify(manager2).rollback(42L); + } + + @Test + void rollback_returnsFalseWhenNoRollbackSucceeds_andCallsAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.rollback(42L)).thenReturn(false); + Mockito.when(manager2.rollback(42L)).thenReturn(false); + + Assertions.assertFalse(bounded.rollback(42L)); + + Mockito.verify(manager1).rollback(42L); + Mockito.verify(manager2).rollback(42L); + } + + @Test + void rollbackInFlightMessages_delegatesToAll() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + bounded.rollbackInFlightMessages(); + + Mockito.verify(manager1).rollbackInFlightMessages(); + Mockito.verify(manager2).rollbackInFlightMessages(); + } + + @Test + void delete_delegatesToAll() throws IOException { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + bounded.delete(); + + Mockito.verify(manager1).delete(); + Mockito.verify(manager2).delete(); + } + + @Test + void size_sumsUnderlyingSizes() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.size()).thenReturn(3); + Mockito.when(manager2.size()).thenReturn(5); + + Assertions.assertEquals(8, bounded.size()); + } + + @Test + void pending_sumsUnderlyingPending() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.pending()).thenReturn(2); + Mockito.when(manager2.pending()).thenReturn(7); + + Assertions.assertEquals(9, bounded.pending()); + } + + @Test + void hasMessagesInFlight_returnsTrueIfAnyHasMessagesInFlight() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.hasMessagesInFlight()).thenReturn(false); + Mockito.when(manager2.hasMessagesInFlight()).thenReturn(true); + + Assertions.assertTrue(bounded.hasMessagesInFlight()); + } + + @Test + void hasAtRestMessages_returnsTrueIfAnyHasAtRestMessages() { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.hasAtRestMessages()).thenReturn(true); + Mockito.when(manager2.hasAtRestMessages()).thenReturn(false); + + Assertions.assertTrue(bounded.hasAtRestMessages()); + } + + @Test + void close_delegatesToAll_andClearsMembership() throws IOException { + BoundedMessageStateManager bounded = new BoundedMessageStateManager(); + + MessageStateManagerImpl manager1 = Mockito.mock(MessageStateManagerImpl.class); + MessageStateManagerImpl manager2 = Mockito.mock(MessageStateManagerImpl.class); + + bounded.add(manager1); + bounded.add(manager2); + + Mockito.when(manager1.size()).thenReturn(1); + Mockito.when(manager2.size()).thenReturn(2); + Assertions.assertEquals(3, bounded.size()); + + bounded.close(); + + Mockito.verify(manager1).close(); + Mockito.verify(manager2).close(); + + Assertions.assertEquals(0, bounded.size()); + Assertions.assertFalse(bounded.hasAtRestMessages()); + Assertions.assertFalse(bounded.hasMessagesInFlight()); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/state/IteratorStateManagerImplTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/IteratorStateManagerImplTest.java new file mode 100644 index 000000000..025e6d252 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/IteratorStateManagerImplTest.java @@ -0,0 +1,141 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.state; + +import io.mapsmessaging.engine.Constants; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactoryImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; + +class IteratorStateManagerImplTest { + + @Test + void constructor_deepCopyTrue_copiesParentsAtRest() throws IOException { + BitSetFactoryImpl factory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + MessageStateManagerImpl realParent = + new MessageStateManagerImpl("parent", 100L, factory); + MessageStateManagerImpl parent = Mockito.spy(realParent); + + parent.register(10L); + parent.register(20L); + + IteratorStateManagerImpl iterator = + new IteratorStateManagerImpl("iter", 200L, parent, true); + + Assertions.assertTrue(iterator.hasAtRestMessages()); + Assertions.assertTrue(iterator.hasMessage(10L)); + Assertions.assertTrue(iterator.hasMessage(20L)); + + iterator.close(); + parent.close(); + } + + @Test + void constructor_deepCopyFalse_startsEmpty() throws IOException { + BitSetFactoryImpl factory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + MessageStateManagerImpl parent = + Mockito.spy(new MessageStateManagerImpl("parent", 101L, factory)); + + parent.register(10L); + parent.register(20L); + + IteratorStateManagerImpl iterator = + new IteratorStateManagerImpl("iter", 201L, parent, false); + + Assertions.assertFalse(iterator.hasAtRestMessages()); + Assertions.assertFalse(iterator.hasMessage(10L)); + Assertions.assertFalse(iterator.hasMessage(20L)); + + iterator.close(); + parent.close(); + } + + @Test + void close_unregistersListenerFromParent() throws IOException { + BitSetFactoryImpl factory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + MessageStateManagerImpl parent = + Mockito.spy(new MessageStateManagerImpl("parent", 102L, factory)); + + IteratorStateManagerImpl iterator = + new IteratorStateManagerImpl("iter", 202L, parent, false); + + Mockito.verify(parent).add(Mockito.any(MessageStateManagerListener.class)); + + iterator.close(); + + Mockito.verify(parent).remove(Mockito.any(MessageStateManagerListener.class)); + parent.close(); + } + + @Test + void parentCommit_triggersIteratorRemove() throws IOException { + BitSetFactoryImpl factory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + MessageStateManagerImpl parent = + new MessageStateManagerImpl("parent", 103L, factory); + + parent.register(55L); + + IteratorStateManagerImpl iterator = + new IteratorStateManagerImpl("iter", 203L, parent, true); + + Assertions.assertTrue(iterator.hasMessage(55L)); + + // parent commit notifies listeners with remove(messageId) + parent.commit(55L); + + Assertions.assertFalse(iterator.hasMessage(55L)); + Assertions.assertFalse(iterator.hasAtRestMessages()); + + iterator.close(); + parent.close(); + } + + @Test + void parentRegisterAfterIteratorCreation_doesNotAppearInIterator() throws IOException { + BitSetFactoryImpl factory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + MessageStateManagerImpl parent = + new MessageStateManagerImpl("parent", 104L, factory); + + parent.register(1L); + + IteratorStateManagerImpl iterator = + new IteratorStateManagerImpl("iter", 204L, parent, true); + + Assertions.assertTrue(iterator.hasMessage(1L)); + Assertions.assertFalse(iterator.hasMessage(2L)); + + // IteratorStateManagerImpl.add(...) is intentionally a no-op, + // so newly registered messages should NOT show up. + parent.register(2L); + + Assertions.assertFalse(iterator.hasMessage(2L)); + + iterator.close(); + parent.close(); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/state/LimitedMessageStateManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/LimitedMessageStateManagerTest.java new file mode 100644 index 000000000..b6d6fbbf3 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/LimitedMessageStateManagerTest.java @@ -0,0 +1,131 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.state; + +import io.mapsmessaging.api.features.Priority; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.Constants; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactoryImpl; +import io.mapsmessaging.utilities.queue.EventReaperQueue; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Queue; + +class LimitedMessageStateManagerTest { + + @Test + void register_underLimit_doesNotEnqueueReap() { + EventReaperQueue eventReaperQueue = new EventReaperQueue(); + BitSetFactoryImpl bitSetFactory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + LimitedMessageStateManager manager = + new LimitedMessageStateManager("test", 1L, bitSetFactory, 3, eventReaperQueue); + + manager.register(10L); + manager.register(20L); + manager.register(30L); + + Queue reaped = eventReaperQueue.getAndClear(); + Assertions.assertTrue(reaped.isEmpty()); + + Assertions.assertTrue(manager.hasMessage(10L)); + Assertions.assertTrue(manager.hasMessage(20L)); + Assertions.assertTrue(manager.hasMessage(30L)); + } + + @Test + void register_overLimit_enqueuesLastForReaping() { + EventReaperQueue eventReaperQueue = new EventReaperQueue(); + BitSetFactoryImpl bitSetFactory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + LimitedMessageStateManager manager = + new LimitedMessageStateManager("test", 1L, bitSetFactory, 2, eventReaperQueue); + + manager.register(10L); + manager.register(20L); + + Queue reapedBefore = eventReaperQueue.getAndClear(); + Assertions.assertTrue(reapedBefore.isEmpty()); + + manager.register(30L); + + Queue reapedAfter = eventReaperQueue.getAndClear(); + Assertions.assertFalse(reapedAfter.isEmpty()); + Assertions.assertTrue(reapedAfter.contains(10L)); + } + + @Test + void register_multipleOverLimit_addsNewLastValues() { + EventReaperQueue eventReaperQueue = new EventReaperQueue(); + BitSetFactoryImpl bitSetFactory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + LimitedMessageStateManager manager = + new LimitedMessageStateManager("test", 1L, bitSetFactory, 2, eventReaperQueue); + + manager.register(1L); + manager.register(2L); + manager.register(3L); + manager.register(4L); + + Queue reaped = eventReaperQueue.getAndClear(); + + // We only assert that once we're over the limit, it queues "last" candidates for reaping. + Assertions.assertTrue(reaped.contains(1L)); + Assertions.assertTrue(reaped.contains(2L)); + } + + @Test + void register_messageUsesIdentifierAndPriority_andCanTriggerReaping() { + EventReaperQueue eventReaperQueue = new EventReaperQueue(); + BitSetFactoryImpl bitSetFactory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + + LimitedMessageStateManager manager = + new LimitedMessageStateManager("test", 1L, bitSetFactory, 1, eventReaperQueue); + + Priority priority = Mockito.mock(Priority.class); + + + Message message1 = Mockito.mock(Message.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(message1.getIdentifier()).thenReturn(100L); + Mockito.when(message1.getPriority()).thenReturn(priority); + Mockito.when(priority.getValue()).thenReturn(Priority.HIGHEST.getValue()); + + Message message2 = Mockito.mock(Message.class); + + Mockito.when(message2.getIdentifier()).thenReturn(200L); + Mockito.when(message2.getPriority()).thenReturn(priority); + Mockito.when(priority.getValue()).thenReturn(Priority.HIGHEST.getValue()); + + + manager.register(message1); + + Queue reapedBefore = eventReaperQueue.getAndClear(); + Assertions.assertTrue(reapedBefore.isEmpty()); + + manager.register(message2); + + Queue reapedAfter = eventReaperQueue.getAndClear(); + Assertions.assertFalse(reapedAfter.isEmpty()); + // "last" should be the later id (given you're just adding ids and trimming by last()) + Assertions.assertTrue(reapedAfter.contains(100L)); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/state/MessageStateManagerImplTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/MessageStateManagerImplTest.java new file mode 100644 index 000000000..072bb2b60 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/state/MessageStateManagerImplTest.java @@ -0,0 +1,361 @@ +package io.mapsmessaging.engine.destination.subscription.state; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.Constants; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactory; +import io.mapsmessaging.utilities.collections.bitset.BitSetFactoryImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; + +class MessageStateManagerImplTest { + + private static final String MANAGER_NAME = "test-manager"; + private static final long UNIQUE_SESSION_ID = 12345L; + + @Test + void newInstance_isEmpty() { + MessageStateManagerImpl manager = createManager(); + + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertEquals(0, manager.size()); + Assertions.assertEquals(0, manager.pending()); + Assertions.assertFalse(manager.hasMessagesInFlight()); + Assertions.assertFalse(manager.hasAtRestMessages()); + Assertions.assertEquals(-1L, manager.nextMessageId()); + } + + @Test + void register_message_addsToAtRest_andIsVisibleInQueries() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(10L, 3); + + manager.register(message); + + Assertions.assertFalse(manager.isEmpty()); + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertFalse(manager.hasMessagesInFlight()); + Assertions.assertEquals(1, manager.pending()); + Assertions.assertEquals(1, manager.size()); + Assertions.assertTrue(manager.hasMessage(10L)); + + Queue atRest = manager.getAllAtRest(); + Assertions.assertEquals(1, atRest.size()); + Assertions.assertTrue(atRest.contains(10L)); + + Queue all = manager.getAll(); + Assertions.assertEquals(1, all.size()); + Assertions.assertTrue(all.contains(10L)); + + Assertions.assertEquals(10L, manager.nextMessageId()); + } + + @Test + void register_messageId_addsToAtRest() { + MessageStateManagerImpl manager = createManager(); + + manager.register(42L); + + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertEquals(1, manager.pending()); + Assertions.assertTrue(manager.hasMessage(42L)); + Assertions.assertEquals(42L, manager.nextMessageId()); + } + + @Test + void allocate_movesMessageFromAtRestToInFlight_whenPresent() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(100L, 2); + manager.register(message); + + manager.allocate(message); + + Assertions.assertFalse(manager.hasAtRestMessages()); + Assertions.assertTrue(manager.hasMessagesInFlight()); + Assertions.assertEquals(0, manager.pending()); + Assertions.assertEquals(1, manager.size()); + Assertions.assertTrue(manager.hasMessage(100L)); + + Queue all = manager.getAll(); + Assertions.assertEquals(1, all.size()); + Assertions.assertTrue(all.contains(100L)); + } + + @Test + void allocate_doesNotLeaveInFlight_whenNotAtRest() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(200L, 1); + + manager.allocate(message); + + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertEquals(0, manager.size()); + Assertions.assertFalse(manager.hasMessage(200L)); + } + + @Test + void commit_removesMessageFromInFlight() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(300L, 1); + manager.register(message); + manager.allocate(message); + + manager.commit(300L); + + Assertions.assertTrue(manager.isEmpty()); + Assertions.assertEquals(0, manager.size()); + Assertions.assertFalse(manager.hasMessage(300L)); + } + + @Test + void rollback_movesMessageFromInFlightBackToAtRest() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(400L, 1); + manager.register(message); + manager.allocate(message); + + boolean rolledBack = manager.rollback(400L); + + Assertions.assertTrue(rolledBack); + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertFalse(manager.hasMessagesInFlight()); + Assertions.assertTrue(manager.hasMessage(400L)); + Assertions.assertEquals(1, manager.pending()); + Assertions.assertEquals(400L, manager.nextMessageId()); + } + + @Test + void rollback_returnsFalse_whenMessageNotInFlight() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(500L, 1); + manager.register(message); + + boolean rolledBack = manager.rollback(500L); + + Assertions.assertFalse(rolledBack); + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertFalse(manager.hasMessagesInFlight()); + Assertions.assertTrue(manager.hasMessage(500L)); + } + + @Test + void rollbackInFlightMessages_movesAllBackToAtRest_andClearsInFlight() { + MessageStateManagerImpl manager = createManager(); + + Message message1 = createMessage(600L, 1); + Message message2 = createMessage(601L, 1); + manager.register(message1); + manager.register(message2); + + manager.allocate(message1); + manager.allocate(message2); + + Assertions.assertTrue(manager.hasMessagesInFlight()); + Assertions.assertFalse(manager.hasAtRestMessages()); + + manager.rollbackInFlightMessages(); + + Assertions.assertFalse(manager.hasMessagesInFlight()); + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertEquals(2, manager.pending()); + Assertions.assertTrue(manager.hasMessage(600L)); + Assertions.assertTrue(manager.hasMessage(601L)); + } + + @Test + void expired_removesFromBothAtRestAndInFlight() { + MessageStateManagerImpl manager = createManager(); + + Message message = createMessage(700L, 1); + manager.register(message); + manager.allocate(message); + + Assertions.assertTrue(manager.hasMessage(700L)); + + manager.expired(700L); + + Assertions.assertFalse(manager.hasMessage(700L)); + Assertions.assertTrue(manager.isEmpty()); + } + + @Test + void register_notifiesListeners() { + MessageStateManagerImpl manager = createManager(); + RecordingMessageStateManagerListener listener = new RecordingMessageStateManagerListener(); + manager.add(listener); + + Message message = createMessage(800L, 9); + + manager.register(message); + + Assertions.assertEquals(1, listener.addCalls.size()); + Assertions.assertEquals(0, listener.addAllCalls.size()); + Assertions.assertEquals(0, listener.removeCalls.size()); + + RecordedAddCall addCall = listener.addCalls.get(0); + Assertions.assertEquals(800L, addCall.messageIdentifier); + Assertions.assertEquals(9, addCall.priority); + } + + @Test + void commit_notifiesListeners_remove() { + MessageStateManagerImpl manager = createManager(); + RecordingMessageStateManagerListener listener = new RecordingMessageStateManagerListener(); + manager.add(listener); + + Message message = createMessage(900L, 1); + manager.register(message); + manager.allocate(message); + + listener.reset(); + + manager.commit(900L); + + Assertions.assertEquals(0, listener.addCalls.size()); + Assertions.assertEquals(0, listener.addAllCalls.size()); + Assertions.assertEquals(1, listener.removeCalls.size()); + Assertions.assertEquals(900L, listener.removeCalls.get(0)); + } + + @Test + void rollbackInFlightMessages_notifiesListeners_addAll_beforeRollback() { + MessageStateManagerImpl manager = createManager(); + RecordingMessageStateManagerListener listener = new RecordingMessageStateManagerListener(); + manager.add(listener); + + Message message1 = createMessage(1000L, 1); + Message message2 = createMessage(1001L, 1); + + manager.register(message1); + manager.register(message2); + manager.allocate(message1); + manager.allocate(message2); + + listener.reset(); + + manager.rollbackInFlightMessages(); + + Assertions.assertEquals(0, listener.addCalls.size()); + Assertions.assertEquals(1, listener.addAllCalls.size()); + Assertions.assertEquals(0, listener.removeCalls.size()); + + Set seen = listener.addAllCalls.get(0); + Assertions.assertTrue(seen.contains(1000L)); + Assertions.assertTrue(seen.contains(1001L)); + } + + @Test + void register_allPriorities_fromLowestToHighest_nextMessageId_returnsHighestFirst_andAllPresent() { + MessageStateManagerImpl manager = createManager(); + + // Register one message per priority value (0..10) + for (int priorityValue = 0; priorityValue <= io.mapsmessaging.api.features.Priority.HIGHEST.getValue(); priorityValue++) { + long messageId = 1000L + priorityValue; + Message message = createMessage(messageId, priorityValue); + manager.register(message); + } + + int expectedCount = io.mapsmessaging.api.features.Priority.HIGHEST.getValue() + 1; + + Assertions.assertEquals(expectedCount, manager.pending()); + Assertions.assertEquals(expectedCount, manager.size()); + Assertions.assertTrue(manager.hasAtRestMessages()); + Assertions.assertFalse(manager.hasMessagesInFlight()); + + for (int priorityValue = 0; priorityValue <= io.mapsmessaging.api.features.Priority.HIGHEST.getValue(); priorityValue++) { + long messageId = 1000L + priorityValue; + Assertions.assertTrue(manager.hasMessage(messageId)); + } + + // Expect highest priority to be delivered first. + // If your PriorityQueue uses the opposite ordering, flip the loop. + for (int priorityValue = io.mapsmessaging.api.features.Priority.HIGHEST.getValue(); priorityValue >= 0; priorityValue--) { + long expectedNextId = 1000L + priorityValue; + long actualNextId = manager.nextMessageId(); + Assertions.assertEquals(expectedNextId, actualNextId); + + Message message = createMessage(expectedNextId, priorityValue); + manager.allocate(message); + + Assertions.assertTrue(manager.hasMessage(expectedNextId)); + Assertions.assertTrue(manager.hasMessagesInFlight() || manager.pending() > 0); + } + + Assertions.assertEquals(0, manager.pending()); + Assertions.assertEquals(expectedCount, manager.size()); + Assertions.assertTrue(manager.hasMessagesInFlight()); + Assertions.assertFalse(manager.hasAtRestMessages()); + Assertions.assertEquals(-1L, manager.nextMessageId()); + } + + + private MessageStateManagerImpl createManager() { + BitSetFactory bitSetFactory = new BitSetFactoryImpl(Constants.BITSET_BLOCK_SIZE); + return new MessageStateManagerImpl(MANAGER_NAME, UNIQUE_SESSION_ID, bitSetFactory); + } + + private Message createMessage(long id, int priorityValue) { + Message message = Mockito.mock(Message.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(message.getIdentifier()).thenReturn(id); + Mockito.when(message.getPriority().getValue()).thenReturn(priorityValue); + return message; + } + + private static final class RecordingMessageStateManagerListener implements MessageStateManagerListener { + + private final List addCalls; + private final List> addAllCalls; + private final List removeCalls; + + private RecordingMessageStateManagerListener() { + this.addCalls = new ArrayList<>(); + this.addAllCalls = new ArrayList<>(); + this.removeCalls = new ArrayList<>(); + } + + @Override + public void add(long messageIdentifier, int priority) { + addCalls.add(new RecordedAddCall(messageIdentifier, priority)); + } + + @Override + public void addAll(Collection queue) { + addAllCalls.add(new HashSet<>(queue)); + } + + @Override + public void remove(long messageIdentifier) { + removeCalls.add(messageIdentifier); + } + + private void reset() { + addCalls.clear(); + addAllCalls.clear(); + removeCalls.clear(); + } + } + + private static final class RecordedAddCall { + + private final long messageIdentifier; + private final int priority; + + private RecordedAddCall(long messageIdentifier, int priority) { + this.messageIdentifier = messageIdentifier; + this.priority = priority; + } + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/AutoAcknowledgementControllerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/AutoAcknowledgementControllerTest.java new file mode 100644 index 000000000..1ad158e91 --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/AutoAcknowledgementControllerTest.java @@ -0,0 +1,173 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.transaction; + +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.OutstandingEventDetails; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +class AutoAcknowledgementControllerTest { + + @Test + void type_isAuto() { + CreditManager creditManager = new FixedCreditManager(10); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + Assertions.assertEquals("auto", controller.getType()); + } + + @Test + void sent_addsOutstanding_andDoesNotChangeFixedCredit() { + CreditManager creditManager = new FixedCreditManager(5); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + Message message = createMessage(123L, 7); + controller.sent(message); + + Assertions.assertEquals(1, controller.size()); + Assertions.assertEquals(5, creditManager.getCurrentCredit()); + + List outstanding = controller.getOutstanding(); + Assertions.assertEquals(1, outstanding.size()); + Assertions.assertEquals(123L, outstanding.get(0).getId()); + Assertions.assertEquals(7, outstanding.get(0).getPriority()); + } + + @Test + void ack_removesFirstOutstanding_ignoresMessageId() { + CreditManager creditManager = new FixedCreditManager(10); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + controller.sent(createMessage(1L, 1)); + controller.sent(createMessage(2L, 1)); + controller.sent(createMessage(3L, 1)); + + Assertions.assertEquals(3, controller.size()); + + controller.ack(9999L); + + Assertions.assertEquals(2, controller.size()); + Assertions.assertEquals(2L, controller.getOutstanding().get(0).getId()); + } + + @Test + void rollback_delegatesToAck() { + CreditManager creditManager = new FixedCreditManager(10); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + controller.sent(createMessage(10L, 1)); + controller.sent(createMessage(11L, 1)); + + controller.rollback(10L); + + Assertions.assertEquals(1, controller.size()); + Assertions.assertEquals(11L, controller.getOutstanding().get(0).getId()); + } + + @Test + void messageSent_removesFirstOutstanding_returnsId_andDoesNotChangeFixedCredit() { + CreditManager creditManager = new FixedCreditManager(2); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + controller.sent(createMessage(100L, 1)); + controller.sent(createMessage(200L, 1)); + + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + + long first = controller.messageSent(); + Assertions.assertEquals(100L, first); + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + Assertions.assertEquals(1, controller.size()); + Assertions.assertEquals(200L, controller.getOutstanding().get(0).getId()); + + long second = controller.messageSent(); + Assertions.assertEquals(200L, second); + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + Assertions.assertEquals(0, controller.size()); + } + + @Test + void messageSent_whenEmpty_returnsMinusOne_andDoesNotChangeCredit() { + CreditManager creditManager = new FixedCreditManager(3); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + long result = controller.messageSent(); + + Assertions.assertEquals(-1L, result); + Assertions.assertEquals(3, creditManager.getCurrentCredit()); + } + + @Test + void canSend_isBasedOnOutstandingSizeComparedToCurrentCredit_fixedCredit() { + CreditManager creditManager = new FixedCreditManager(3); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + Assertions.assertTrue(controller.canSend()); + + controller.sent(createMessage(1L, 1)); + Assertions.assertTrue(controller.canSend()); + + controller.sent(createMessage(2L, 1)); + Assertions.assertTrue(controller.canSend()); + + controller.sent(createMessage(3L, 1)); + Assertions.assertFalse(controller.canSend()); + } + + @Test + void setMaxOutstanding_updatesCredit_andReturnsCanSend() { + CreditManager creditManager = new FixedCreditManager(1); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + controller.sent(createMessage(1L, 1)); + + boolean canSendAfterSet = controller.setMaxOutstanding(10); + + Assertions.assertEquals(10, creditManager.getCurrentCredit()); + Assertions.assertTrue(canSendAfterSet); + } + + @Test + void close_clearsOutstanding() { + CreditManager creditManager = new FixedCreditManager(10); + AutoAcknowledgementController controller = new AutoAcknowledgementController(creditManager); + + controller.sent(createMessage(1L, 1)); + controller.sent(createMessage(2L, 1)); + + Assertions.assertEquals(2, controller.size()); + + controller.close(); + + Assertions.assertEquals(0, controller.size()); + Assertions.assertTrue(controller.getOutstanding().isEmpty()); + } + + private Message createMessage(long id, int priorityValue) { + Message message = Mockito.mock(Message.class, Mockito.RETURNS_DEEP_STUBS); + Mockito.when(message.getIdentifier()).thenReturn(id); + Mockito.when(message.getPriority().getValue()).thenReturn(priorityValue); + return message; + } +} diff --git a/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/CreditManagerTest.java b/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/CreditManagerTest.java new file mode 100644 index 000000000..f625dfc8a --- /dev/null +++ b/src/test/java/io/mapsmessaging/engine/destination/subscription/transaction/CreditManagerTest.java @@ -0,0 +1,133 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.engine.destination.subscription.transaction; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class CreditManagerTest { + + @Test + void fixedCreditManager_whenInitialCreditIsLessThanOne_defaultsTo32() { + FixedCreditManager creditManager = new FixedCreditManager(0); + Assertions.assertEquals(32, creditManager.getCurrentCredit()); + + FixedCreditManager creditManagerNegative = new FixedCreditManager(-5); + Assertions.assertEquals(32, creditManagerNegative.getCurrentCredit()); + } + + @Test + void fixedCreditManager_whenInitialCreditIsValid_keepsValue() { + FixedCreditManager creditManager = new FixedCreditManager(1); + Assertions.assertEquals(1, creditManager.getCurrentCredit()); + + FixedCreditManager creditManagerLarge = new FixedCreditManager(100); + Assertions.assertEquals(100, creditManagerLarge.getCurrentCredit()); + } + + @Test + void fixedCreditManager_incrementAndDecrement_doNotChangeCredit() { + FixedCreditManager creditManager = new FixedCreditManager(10); + + creditManager.decrement(); + creditManager.decrement(); + creditManager.increment(); + creditManager.increment(); + + Assertions.assertEquals(10, creditManager.getCurrentCredit()); + } + + @Test + void clientCreditManager_decrement_reducesCredit() { + ClientCreditManager creditManager = new ClientCreditManager(3); + + Assertions.assertEquals(3, creditManager.getCurrentCredit()); + + creditManager.decrement(); + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + + creditManager.decrement(); + Assertions.assertEquals(1, creditManager.getCurrentCredit()); + + creditManager.decrement(); + Assertions.assertEquals(0, creditManager.getCurrentCredit()); + } + + @Test + void clientCreditManager_increment_doesNotChangeCredit() { + ClientCreditManager creditManager = new ClientCreditManager(3); + + creditManager.increment(); + Assertions.assertEquals(3, creditManager.getCurrentCredit()); + + creditManager.decrement(); + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + + creditManager.increment(); + Assertions.assertEquals(2, creditManager.getCurrentCredit()); + } + + @Test + void setCurrentCredit_updatesValue() { + ClientCreditManager creditManager = new ClientCreditManager(5); + + Assertions.assertEquals(5, creditManager.getCurrentCredit()); + + creditManager.setCurrentCredit(10); + Assertions.assertEquals(10, creditManager.getCurrentCredit()); + + creditManager.setCurrentCredit(0); + Assertions.assertEquals(0, creditManager.getCurrentCredit()); + } + + @Test + void setCurrentCredit_sameValue_doesNotChangeObservedValue() { + ClientCreditManager creditManager = new ClientCreditManager(5); + + creditManager.setCurrentCredit(5); + + Assertions.assertEquals(5, creditManager.getCurrentCredit()); + } + + @Test + void clientCreditManager_canGoNegative_ifDecrementCalledTooOften() { + ClientCreditManager creditManager = new ClientCreditManager(1); + + creditManager.decrement(); + Assertions.assertEquals(0, creditManager.getCurrentCredit()); + + creditManager.decrement(); + Assertions.assertEquals(-1, creditManager.getCurrentCredit()); + } + + @Test + void fixedCreditManager_setCurrentCredit_canOverrideEvenThoughIncrementDecrementAreNoOps() { + FixedCreditManager creditManager = new FixedCreditManager(10); + + creditManager.setCurrentCredit(99); + + Assertions.assertEquals(99, creditManager.getCurrentCredit()); + + creditManager.decrement(); + creditManager.increment(); + + Assertions.assertEquals(99, creditManager.getCurrentCredit()); + } +} diff --git a/src/test/java/io/mapsmessaging/engine/security/BaseLoginModule.java b/src/test/java/io/mapsmessaging/engine/security/BaseLoginModule.java index 8c73d4f33..9692aa83c 100644 --- a/src/test/java/io/mapsmessaging/engine/security/BaseLoginModule.java +++ b/src/test/java/io/mapsmessaging/engine/security/BaseLoginModule.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/security/TestLoginModule.java b/src/test/java/io/mapsmessaging/engine/security/TestLoginModule.java index 94c866731..b3da70a89 100644 --- a/src/test/java/io/mapsmessaging/engine/security/TestLoginModule.java +++ b/src/test/java/io/mapsmessaging/engine/security/TestLoginModule.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/EngineManager.java b/src/test/java/io/mapsmessaging/engine/session/EngineManager.java index 4776a221b..0cbe727d2 100644 --- a/src/test/java/io/mapsmessaging/engine/session/EngineManager.java +++ b/src/test/java/io/mapsmessaging/engine/session/EngineManager.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/FakeEndPoint.java b/src/test/java/io/mapsmessaging/engine/session/FakeEndPoint.java index 9555cd53a..210a4b0ce 100644 --- a/src/test/java/io/mapsmessaging/engine/session/FakeEndPoint.java +++ b/src/test/java/io/mapsmessaging/engine/session/FakeEndPoint.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -77,4 +77,9 @@ public String getName() { protected Logger createLogger() { return null; } + + @Override + public String getRemoteSocketAddress() { + return ""; + } } diff --git a/src/test/java/io/mapsmessaging/engine/session/FakeProtocol.java b/src/test/java/io/mapsmessaging/engine/session/FakeProtocol.java index 7868ddf7a..0844b1f7d 100644 --- a/src/test/java/io/mapsmessaging/engine/session/FakeProtocol.java +++ b/src/test/java/io/mapsmessaging/engine/session/FakeProtocol.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/FakeSecurityManager.java b/src/test/java/io/mapsmessaging/engine/session/FakeSecurityManager.java index f21700664..e3e4a806b 100644 --- a/src/test/java/io/mapsmessaging/engine/session/FakeSecurityManager.java +++ b/src/test/java/io/mapsmessaging/engine/session/FakeSecurityManager.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/MessageEngineTest.java b/src/test/java/io/mapsmessaging/engine/session/MessageEngineTest.java index 19fceeccb..267b48049 100644 --- a/src/test/java/io/mapsmessaging/engine/session/MessageEngineTest.java +++ b/src/test/java/io/mapsmessaging/engine/session/MessageEngineTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/ProtocolMessageListener.java b/src/test/java/io/mapsmessaging/engine/session/ProtocolMessageListener.java index 0db17d682..ac21862a7 100644 --- a/src/test/java/io/mapsmessaging/engine/session/ProtocolMessageListener.java +++ b/src/test/java/io/mapsmessaging/engine/session/ProtocolMessageListener.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/engine/session/SessionManagerTest.java b/src/test/java/io/mapsmessaging/engine/session/SessionManagerTest.java index 213316278..613748bee 100644 --- a/src/test/java/io/mapsmessaging/engine/session/SessionManagerTest.java +++ b/src/test/java/io/mapsmessaging/engine/session/SessionManagerTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -50,6 +50,10 @@ public boolean hasIdleSessions(){ return manager.hasIdleSessions(); } + public int sessionCount(){ + return manager.getSessions().size(); + } + public boolean hasSessions() { return manager.hasSessions(); } diff --git a/src/test/java/io/mapsmessaging/ha/FileLockManagerTest.java b/src/test/java/io/mapsmessaging/ha/FileLockManagerTest.java new file mode 100644 index 000000000..9717d277f --- /dev/null +++ b/src/test/java/io/mapsmessaging/ha/FileLockManagerTest.java @@ -0,0 +1,173 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.ha; + +import io.mapsmessaging.utilities.GsonFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class FileLockManagerTest { + + @TempDir + Path tempDir; + + @Test + void tryAcquireLockWithTakeover_createsLockFile_andHeartbeatFile_andWritesPid() throws Exception { + Path lockFile = tempDir.resolve("maps.lock"); + + AtomicInteger exitCode = new AtomicInteger(Integer.MIN_VALUE); + + try (FileLockManager manager = new FileLockManager(lockFile, 300, exitCode::set)) { + boolean acquired = manager.tryAcquireLockWithTakeover(); + assertTrue(acquired); + assertTrue(manager.isLocked()); + assertEquals(Integer.MIN_VALUE, exitCode.get(), "exit should not be called on normal acquisition"); + + assertTrue(Files.exists(lockFile), "lock file should exist"); + + Path heartbeat = Path.of(lockFile + ".heartbeat"); + assertTrue(waitUntilExists(heartbeat, 2_000), "heartbeat file should appear"); + + String content = Files.readString(heartbeat).trim(); + assertFalse(content.isEmpty(), "heartbeat should contain JSON"); + + LockInfo info = GsonFactory.getInstance().getSimpleGson().fromJson(content, LockInfo.class); + assertNotNull(info); + + assertEquals(ProcessHandle.current().pid(), info.getPid(), "heartbeat should contain our pid"); + assertNotNull(info.getHostname()); + assertNotNull(info.getStarted()); + assertNotNull(info.getVersion()); + assertNotNull(info.getBuildDate()); + assertNotNull(info.getLastHeartbeat()); + + OffsetDateTime.parse(info.getLastHeartbeat()); + } + } + + + @Test + void close_releasesLock_andDeletesHeartbeat_andDeletesStopSignal() throws Exception { + Path lockFile = tempDir.resolve("maps.lock"); + + FileLockManager manager = new FileLockManager(lockFile, 300, code ->{}); + assertTrue(manager.tryAcquireLockWithTakeover()); + + Path heartbeat = Path.of(lockFile + ".heartbeat"); + assertTrue(waitUntilExists(heartbeat, 2_000)); + + Path stopSignal = Path.of(lockFile + ".stop"); + Files.writeString(stopSignal, "stop"); + assertTrue(Files.exists(stopSignal)); + + manager.close(); + + assertFalse(manager.isLocked()); + assertFalse(Files.exists(heartbeat), "heartbeat should be deleted on close"); + assertFalse(Files.exists(stopSignal), "stop signal should be deleted on close"); + } + + @Test + void secondManager_inSameJvm_shouldNotCrash_andShouldReturnFalseWhenShutdown_requested() throws Exception { + Path lockFile = tempDir.resolve("maps.lock"); + + FileLockManager manager1 = new FileLockManager(lockFile, 2_000, code ->{}); + assertTrue(manager1.tryAcquireLockWithTakeover()); + assertTrue(manager1.isLocked()); + + FileLockManager manager2 = new FileLockManager(lockFile, 2_000, code ->{}); + + AtomicReference thrown = new AtomicReference<>(); + AtomicReference result = new AtomicReference<>(null); + CountDownLatch started = new CountDownLatch(1); + Thread t = new Thread(() -> { + started.countDown(); + try { + result.set(manager2.tryAcquireLockWithTakeover()); + } catch (Throwable t1) { + thrown.set(t1); + } + }, "lock-attempt-2"); + t.setDaemon(true); + t.start(); + + assertTrue(started.await(2, TimeUnit.SECONDS)); + Thread.sleep(200); + manager2.shutdown(); + + t.join(3_000); + + // This is the key enforcement: + // A second attempt in the same JVM frequently throws OverlappingFileLockException. + // That should NOT blow up the manager. It should behave like "lock not available". + Throwable t1 = thrown.get(); + if (t1 != null) { + // Make the failure loud and specific so it’s obvious what needs fixing. + if (t1 instanceof OverlappingFileLockException) { + fail("Second lock attempt in same JVM threw OverlappingFileLockException. " + + "tryAcquireLockWithTakeover() should treat it like 'lock unavailable' and retry/sleep."); + } + fail("Unexpected exception from second manager: " + t1.getClass().getName() + " " + t1.getMessage()); + } + + assertNotNull(result.get(), "second manager should have returned"); + assertFalse(result.get(), "second manager should not acquire while first holds"); + assertFalse(manager2.isLocked()); + + manager2.close(); + manager1.close(); + } + + @Test + void tryAcquireLockWithTakeover_deletesStaleStopSignalFile_whenAcquired() throws Exception { + Path lockFile = tempDir.resolve("maps.lock"); + Path stopSignal = Path.of(lockFile + ".stop"); + + Files.writeString(stopSignal, "stale"); + assertTrue(Files.exists(stopSignal)); + + try (FileLockManager manager = new FileLockManager(lockFile, 300, code -> { })) { + assertTrue(manager.tryAcquireLockWithTakeover()); + assertFalse(Files.exists(stopSignal), "stale stop file should be deleted on successful acquisition"); + } + } + + private static boolean waitUntilExists(Path file, long timeoutMillis) throws InterruptedException { + long end = System.currentTimeMillis() + timeoutMillis; + while (System.currentTimeMillis() < end) { + if (Files.exists(file)) { + return true; + } + Thread.sleep(25); + } + return false; + } +} diff --git a/src/test/java/io/mapsmessaging/license/LicenseServerClientTest.java b/src/test/java/io/mapsmessaging/license/LicenseServerClientTest.java new file mode 100644 index 000000000..3369178f8 --- /dev/null +++ b/src/test/java/io/mapsmessaging/license/LicenseServerClientTest.java @@ -0,0 +1,171 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.license; + +import io.mapsmessaging.logging.Logger; +import io.mapsmessaging.logging.LoggerFactory; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +class LicenseServerClientTest { + + private MockWebServer mockWebServer; + + @BeforeEach + void setup() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (mockWebServer != null) { + mockWebServer.shutdown(); + } + } + + @Test + void fetchLicenses_success_returnsDecodedLicenses() throws Exception { + byte[] licenseBytes = "test-license-bytes".getBytes(StandardCharsets.UTF_8); + String base64License = Base64.getEncoder().encodeToString(licenseBytes); + + String responseJson = + "[" + + "{\"type\":\"community\",\"license\":\"" + base64License + "\"}" + + "]"; + + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(responseJson) + ); + + URI serverUri = mockWebServer.url("/api/v1/license").uri(); + + Logger logger = LoggerFactory.getLogger(LicenseServerClientTest.class); + LicenseServerClient licenseServerClient = new LicenseServerClient(logger, serverUri); + + String clientName = "build-client"; + String clientSecret = "build-secret"; + String uniqueId = "build"; + UUID serverUuid = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + List responses = + licenseServerClient.fetchLicenses(clientName, clientSecret, uniqueId, serverUuid); + + Assertions.assertNotNull(responses); + Assertions.assertEquals(1, responses.size()); + + LicenseServerResponse first = responses.get(0); + Assertions.assertEquals("community", first.getType()); + Assertions.assertArrayEquals(licenseBytes, first.getLicenseContent()); + + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + Assertions.assertEquals("POST", recordedRequest.getMethod()); + Assertions.assertEquals("/api/v1/license", recordedRequest.getPath()); + Assertions.assertEquals("application/json", recordedRequest.getHeader("Content-Type")); + + String postedBody = recordedRequest.getBody().readUtf8(); + Assertions.assertTrue(postedBody.contains("\"clientName\":\"" + clientName + "\"")); + Assertions.assertTrue(postedBody.contains("\"clientSecret\":\"" + clientSecret + "\"")); + Assertions.assertTrue(postedBody.contains("\"uniqueServerId\":\"" + uniqueId + "\"")); + Assertions.assertTrue(postedBody.contains("\"serverUUID\":\"" + serverUuid + "\"")); + } + + @Test + void fetchLicenses_non200_returnsEmptyList() throws Exception { + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", "application/json") + .setBody("{\"error\":\"nope\"}") + ); + + URI serverUri = mockWebServer.url("/api/v1/license").uri(); + + Logger logger = LoggerFactory.getLogger(LicenseServerClientTest.class); + LicenseServerClient licenseServerClient = new LicenseServerClient(logger, serverUri); + + List responses = + licenseServerClient.fetchLicenses("client", "secret", "build", UUID.randomUUID()); + + Assertions.assertNotNull(responses); + Assertions.assertTrue(responses.isEmpty()); + } + + @Test + void fetchLicenses_invalidJson_returnsEmptyList() throws Exception { + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody("this is not json") + ); + + URI serverUri = mockWebServer.url("/api/v1/license").uri(); + + Logger logger = LoggerFactory.getLogger(LicenseServerClientTest.class); + LicenseServerClient licenseServerClient = new LicenseServerClient(logger, serverUri); + + List responses = + licenseServerClient.fetchLicenses("client", "secret", "build", UUID.randomUUID()); + + Assertions.assertNotNull(responses); + Assertions.assertTrue(responses.isEmpty()); + } + + @Test + void fetchLicenses_invalidBase64_skipsEntryOrReturnsEmpty() throws Exception { + String responseJson = + "[" + + "{\"type\":\"community\",\"license\":\"NOT_BASE64!!!!\"}" + + "]"; + + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(responseJson) + ); + + URI serverUri = mockWebServer.url("/api/v1/license").uri(); + + Logger logger = LoggerFactory.getLogger(LicenseServerClientTest.class); + LicenseServerClient licenseServerClient = new LicenseServerClient(logger, serverUri); + + List responses = + licenseServerClient.fetchLicenses("client", "secret", "build", UUID.randomUUID()); + + Assertions.assertNotNull(responses); + Assertions.assertTrue(responses.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/license/tools/LicenseFetcherTest.java b/src/test/java/io/mapsmessaging/license/tools/LicenseFetcherTest.java new file mode 100644 index 000000000..62f6d8961 --- /dev/null +++ b/src/test/java/io/mapsmessaging/license/tools/LicenseFetcherTest.java @@ -0,0 +1,83 @@ +package io.mapsmessaging.license.tools; + +import io.mapsmessaging.license.LicenseFileStore; +import io.mapsmessaging.license.LicenseServerClient; +import io.mapsmessaging.license.LicenseServerResponse; +import io.mapsmessaging.logging.Logger; +import io.mapsmessaging.logging.LoggerFactory; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; + +class LicenseFetcherTest { + + @TempDir + File tempDir; + + @Test + void runMain_noArgs_returns1() throws Exception { + int code = LicenseFetcher.runMain(new String[0]); + Assertions.assertEquals(1, code); + } + + @Test + void runMain_createsDir_andReturns10WhenNoLicenses() throws Exception { + File outputDir = new File(tempDir, "license"); + + Logger logger = LoggerFactory.getLogger(LicenseFetcherTest.class); + + LicenseServerClient client = new FakeLicenseServerClient(logger, List.of()); + LicenseFileStore store = new LicenseFileStore(logger); + + int code = LicenseFetcher.run(outputDir, client, store, "build", UUID.fromString("11111111-2222-3333-4444-555555555555")); + + Assertions.assertEquals(10, code); + Assertions.assertFalse(outputDir.exists() && outputDir.listFiles() != null && outputDir.listFiles().length > 0); + } + + @Test + void run_writesLicenseFile_returns0() throws Exception { + File outputDir = new File(tempDir, "license"); + Assertions.assertTrue(outputDir.mkdirs()); + + byte[] licenseBytes = "community-license".getBytes(StandardCharsets.UTF_8); + + LicenseServerResponse response = new LicenseServerResponse("community", licenseBytes); + + Logger logger = LoggerFactory.getLogger(LicenseFetcherTest.class); + + LicenseServerClient client = new FakeLicenseServerClient(logger, List.of(response)); + LicenseFileStore store = new LicenseFileStore(logger); + + int code = LicenseFetcher.run(outputDir, client, store, "build", UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); + + Assertions.assertEquals(0, code); + + File expected = new File(outputDir, "license_community.lic"); + Assertions.assertTrue(expected.exists()); + + byte[] written = Files.readAllBytes(expected.toPath()); + Assertions.assertArrayEquals(licenseBytes, written); + } + + private static final class FakeLicenseServerClient extends LicenseServerClient { + + private final List responses; + + private FakeLicenseServerClient(Logger logger, List responses) { + super(logger); + this.responses = responses; + } + + @Override + public List fetchLicenses(String clientName, String clientSecret, String uniqueId, UUID serverUUID) { + return responses; + } + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/EndPointURLTest.java b/src/test/java/io/mapsmessaging/network/EndPointURLTest.java index a03a4aa69..d232d9f5f 100644 --- a/src/test/java/io/mapsmessaging/network/EndPointURLTest.java +++ b/src/test/java/io/mapsmessaging/network/EndPointURLTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointConnectionFactoryTest.java b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointConnectionFactoryTest.java index 8f8307ddd..5c6e01780 100644 --- a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointConnectionFactoryTest.java +++ b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointConnectionFactoryTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerFactoryTest.java b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerFactoryTest.java index e694303c6..9da255b32 100644 --- a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerFactoryTest.java +++ b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerFactoryTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerTest.java b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerTest.java index 7745bf1bc..3c671ed03 100644 --- a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerTest.java +++ b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointServerTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointTest.java b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointTest.java index 4f251a9fd..d1c29093e 100644 --- a/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointTest.java +++ b/src/test/java/io/mapsmessaging/network/io/impl/noop/NoOpEndPointTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityFactoryTests.java b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityFactoryTests.java new file mode 100644 index 000000000..1004f6bfc --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityFactoryTests.java @@ -0,0 +1,133 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.io.security; + +import io.mapsmessaging.network.io.Packet; +import io.mapsmessaging.network.io.security.impl.signature.AppenderSignatureManager; +import io.mapsmessaging.network.io.security.impl.signature.PrependerSignatureManager; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Random; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class PacketIntegrityFactoryTests { + + @Test + void getAlgorithms_returnsNonNullList() { + List algorithms = PacketIntegrityFactory.getInstance().getAlgorithms(); + Assertions.assertNotNull(algorithms); + } + + @Test + void getPacketIntegrity_throwsNoSuchAlgorithm_forNullAlgorithm() { + SignatureManager signatureManager = new PrependerSignatureManager(); + byte[] key = new byte[32]; + + Assertions.assertThrows( + NoSuchAlgorithmException.class, + () -> PacketIntegrityFactory.getInstance().getPacketIntegrity(null, signatureManager, key) + ); + } + + @Test + void getPacketIntegrity_throwsNoSuchAlgorithm_forBlankAlgorithm() { + SignatureManager signatureManager = new PrependerSignatureManager(); + byte[] key = new byte[32]; + + Assertions.assertThrows( + NoSuchAlgorithmException.class, + () -> PacketIntegrityFactory.getInstance().getPacketIntegrity(" ", signatureManager, key) + ); + } + + @Test + void getPacketIntegrity_throwsIllegalArgument_forNullSignatureManager() { + byte[] key = new byte[32]; + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> PacketIntegrityFactory.getInstance().getPacketIntegrity("HmacSHA256", null, key) + ); + } + + @Test + void getPacketIntegrity_throwsInvalidKey_forNullKey() { + SignatureManager signatureManager = new PrependerSignatureManager(); + + Assertions.assertThrows( + InvalidKeyException.class, + () -> PacketIntegrityFactory.getInstance().getPacketIntegrity("HmacSHA256", signatureManager, null) + ); + } + + @Test + void getPacketIntegrity_throwsNoSuchAlgorithm_forUnknownAlgorithm() { + SignatureManager signatureManager = new PrependerSignatureManager(); + byte[] key = new byte[32]; + + Assertions.assertThrows( + NoSuchAlgorithmException.class, + () -> PacketIntegrityFactory.getInstance().getPacketIntegrity("DefinitelyNotARealAlgorithm", signatureManager, key) + ); + } + + @Test + void getPacketIntegrity_happyPath_returnsInitialisedIntegrity_andCanSecure() throws Exception { + List algorithms = PacketIntegrityFactory.getInstance().getAlgorithms(); + Assertions.assertNotNull(algorithms); + Assertions.assertFalse(algorithms.isEmpty(), "No PacketIntegrity implementations discovered via ServiceLoader"); + + String algorithm = algorithms.get(0); + + Random random = new Random(1234567L); + byte[] key = new byte[64]; + random.nextBytes(key); + + PacketIntegrityFactory factory = PacketIntegrityFactory.getInstance(); + + PacketIntegrity prependerIntegrity = factory.getPacketIntegrity(algorithm, new PrependerSignatureManager(), key); + Assertions.assertNotNull(prependerIntegrity); + + PacketIntegrity appenderIntegrity = factory.getPacketIntegrity(algorithm, new AppenderSignatureManager(), key); + Assertions.assertNotNull(appenderIntegrity); + + Packet packet = new Packet(2048, false); + for (int index = 0; index < 256; index++) { + packet.put((byte) (index & 0xFF)); + } + packet.flip(); + + Packet securedPrepended = prependerIntegrity.secure(packet); + Assertions.assertNotNull(securedPrepended); + Assertions.assertTrue(prependerIntegrity.isSecure(securedPrepended)); + + Packet packet2 = new Packet(2048, false); + for (int index = 0; index < 256; index++) { + packet2.put((byte) (index & 0xFF)); + } + packet2.flip(); + + Packet securedAppended = appenderIntegrity.secure(packet2); + Assertions.assertNotNull(securedAppended); + Assertions.assertTrue(appenderIntegrity.isSecure(securedAppended)); + } +} diff --git a/src/test/java/io/mapsmessaging/network/io/security/PacketIntegritySecurityTests.java b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegritySecurityTests.java new file mode 100644 index 000000000..dd554c2fb --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegritySecurityTests.java @@ -0,0 +1,275 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.io.security; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import io.mapsmessaging.network.io.Packet; +import io.mapsmessaging.network.io.security.impl.signature.AppenderSignatureManager; +import io.mapsmessaging.network.io.security.impl.signature.PrependerSignatureManager; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class PacketIntegritySecurityTests { + + private static final int PAYLOAD_LENGTH = 1024; + private static final int PACKET_CAPACITY = 2048; + + @ParameterizedTest + @MethodSource + void secure_happyPath_isSecure_andSizeMatches(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + Assertions.assertEquals(PAYLOAD_LENGTH + context.integrity.size(), context.secured.limit()); + Assertions.assertTrue(context.integrity.isSecure(context.secured)); + } + + @ParameterizedTest + @MethodSource + void verify_failsWithWrongKey(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + byte[] wrongKey = new byte[context.key.length]; + new Random(9999L).nextBytes(wrongKey); + + PacketIntegrity wrongIntegrity = PacketIntegrityFactory.getInstance() + .getPacketIntegrity(algorithm, stamper, wrongKey); + + // verify() / isSecure() may UNWRAP on success, so never verify against the shared context packet + Packet securedForVerification = clonePacket(context.secured); + + boolean verifiesWithWrongKey = wrongIntegrity.isSecure(securedForVerification); + if (!verifiesWithWrongKey) { + return; + } + + Assertions.assertTrue( + isUnkeyedChecksumAlgorithm(algorithm), + () -> "Verification succeeded with wrong key for algorithm '" + algorithm + "'. " + + "If this is intentional, add it to isUnkeyedChecksumAlgorithm()." + ); + + Packet securedWithWrongKey = wrongIntegrity.secure(clonePacket(context.payload)); + + // Compare against a non-mutated secured packet + Assertions.assertTrue( + packetsEqual(context.secured, securedWithWrongKey), + () -> "Algorithm '" + algorithm + "' verified with wrong key but produced a different secured packet. " + + "That suggests verification might not be checking the signature correctly." + ); + } + + + @ParameterizedTest + @MethodSource + void verify_failsIfPayloadIsModified(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + Packet tampered = clonePacket(context.secured); + flipOnePayloadByte(tampered, context.integrity.size(), stamper); + + Assertions.assertFalse(context.integrity.isSecure(tampered)); + } + + @ParameterizedTest + @MethodSource + void verify_failsIfSignatureIsModified(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + Packet tampered = clonePacket(context.secured); + flipOneSignatureByte(tampered, context.integrity.size(), stamper); + + Assertions.assertFalse(context.integrity.isSecure(tampered)); + } + + @ParameterizedTest + @MethodSource + void verify_failsIfSignatureIsTruncatedByOneByte(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + int originalLimit = context.secured.limit(); + int signatureSize = context.integrity.size(); + + Assertions.assertTrue(signatureSize > 0, "Signature size must be > 0"); + + Packet truncated = clonePacket(context.secured); + truncated.limit(originalLimit - 1); + + Assertions.assertFalse(context.integrity.isSecure(truncated)); + } + + private static Stream secure_happyPath_isSecure_andSizeMatches() { + return parameters(); + } + + private static Stream verify_failsWithWrongKey() { + return parameters(); + } + + private static Stream verify_failsIfPayloadIsModified() { + return parameters(); + } + + private static Stream verify_failsIfSignatureIsModified() { + return parameters(); + } + + private static Stream verify_failsIfSignatureIsTruncatedByOneByte() { + return parameters(); + } + + private static Stream parameters() { + List list = new ArrayList<>(); + for (String algorithm : PacketIntegrityFactory.getInstance().getAlgorithms()) { + list.add(arguments(algorithm, new PrependerSignatureManager())); + list.add(arguments(algorithm, new AppenderSignatureManager())); + } + return list.stream(); + } + + private static TestContext createContext(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + Random random = new Random(1234567L); + + byte[] key = new byte[100]; + random.nextBytes(key); + + PacketIntegrity packetIntegrity = PacketIntegrityFactory.getInstance().getPacketIntegrity(algorithm, stamper, key); + + Packet payload = createPayloadPacket(); + Packet secured = packetIntegrity.secure(clonePacket(payload)); + + return new TestContext(key, payload, packetIntegrity, secured); + } + + private static Packet createPayloadPacket() { + Packet packet = new Packet(PACKET_CAPACITY, false); + for (int index = 0; index < PAYLOAD_LENGTH; index++) { + packet.put((byte) (index & 0xFF)); + } + packet.flip(); + return packet; + } + + private static Packet clonePacket(Packet source) { + Packet clone = new Packet(source.capacity(), false); + + int limit = source.limit(); + for (int index = 0; index < limit; index++) { + clone.put(source.get(index)); + } + clone.flip(); + clone.limit(limit); + + return clone; + } + + private static boolean packetsEqual(Packet a, Packet b) { + if (a.limit() != b.limit()) { + return false; + } + int limit = a.limit(); + for (int index = 0; index < limit; index++) { + if (a.get(index) != b.get(index)) { + return false; + } + } + return true; + } + + private static boolean isUnkeyedChecksumAlgorithm(String algorithm) { + if (algorithm == null) { + return false; + } + return algorithm.equalsIgnoreCase("Adler32") + || algorithm.equalsIgnoreCase("CRC32") + || algorithm.equalsIgnoreCase("CRC32C"); + } + + private static void flipOnePayloadByte(Packet packet, int signatureSize, SignatureManager stamper) { + int payloadStart; + if (stamper instanceof PrependerSignatureManager) { + payloadStart = signatureSize; + } else { + payloadStart = 0; + } + + int payloadEnd; + if (stamper instanceof PrependerSignatureManager) { + payloadEnd = packet.limit(); + } else { + payloadEnd = packet.limit() - signatureSize; + } + + Assertions.assertTrue(payloadEnd > payloadStart, "No payload region available to tamper"); + + int indexToFlip = payloadStart + ((payloadEnd - payloadStart) / 2); + byte value = packet.get(indexToFlip); + packet.put(indexToFlip, (byte) (value ^ 0x01)); + } + + private static void flipOneSignatureByte(Packet packet, int signatureSize, SignatureManager stamper) { + Assertions.assertTrue(signatureSize > 0, "Signature size must be > 0"); + + int signatureStart; + if (stamper instanceof PrependerSignatureManager) { + signatureStart = 0; + } else { + signatureStart = packet.limit() - signatureSize; + } + + int indexToFlip = signatureStart + (signatureSize / 2); + byte value = packet.get(indexToFlip); + packet.put(indexToFlip, (byte) (value ^ 0x01)); + } + + private static class TestContext { + private final byte[] key; + private final Packet payload; + private final PacketIntegrity integrity; + private final Packet secured; + + private TestContext(byte[] key, Packet payload, PacketIntegrity integrity, Packet secured) { + this.key = key; + this.payload = payload; + this.integrity = integrity; + this.secured = secured; + } + } +} diff --git a/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityVerificationTests.java b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityVerificationTests.java new file mode 100644 index 000000000..6a57022cc --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/io/security/PacketIntegrityVerificationTests.java @@ -0,0 +1,252 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.io.security; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import io.mapsmessaging.network.io.Packet; +import io.mapsmessaging.network.io.security.impl.signature.AppenderSignatureManager; +import io.mapsmessaging.network.io.security.impl.signature.PrependerSignatureManager; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class PacketIntegrityVerificationTests { + + private static final int PAYLOAD_LENGTH = 1024; + private static final int PACKET_CAPACITY = 2048; + + @ParameterizedTest + @MethodSource + void verify_happyPath_returnsOk(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + int packetLengthBeforeVerify = context.secured.limit(); + int signatureSize = context.integrity.size(); + + VerificationResult result = context.integrity.verify(context.secured); + + Assertions.assertTrue(result.isValid()); + Assertions.assertEquals(FailureReason.OK, result.getReason()); + Assertions.assertEquals(algorithm, result.getAlgorithm()); + Assertions.assertEquals(packetLengthBeforeVerify, result.getPacketLength()); + Assertions.assertEquals(signatureSize, result.getSignatureSize()); + + // Optional but actually useful: confirm unwrap happened + Assertions.assertEquals(packetLengthBeforeVerify - signatureSize, context.secured.limit()); + } + + @ParameterizedTest + @MethodSource + void verify_nullPacket_returnsPacketNull(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + PacketIntegrity integrity = createIntegrity(algorithm, stamper); + + VerificationResult result = integrity.verify(null); + + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(FailureReason.PACKET_NULL, result.getReason()); + Assertions.assertEquals(algorithm, result.getAlgorithm()); + } + + @ParameterizedTest + @MethodSource + void verify_tamperPayload_returnsSignatureMismatch(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + Packet tampered = clonePacket(context.secured); + flipOnePayloadByte(tampered, context.integrity.size(), stamper); + + VerificationResult result = context.integrity.verify(tampered); + + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(FailureReason.SIGNATURE_MISMATCH, result.getReason()); + } + + @ParameterizedTest + @MethodSource + void verify_tamperSignature_returnsSignatureMismatch(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + Packet tampered = clonePacket(context.secured); + flipOneSignatureByte(tampered, context.integrity.size(), stamper); + + VerificationResult result = context.integrity.verify(tampered); + + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(FailureReason.SIGNATURE_MISMATCH, result.getReason()); + } + + @ParameterizedTest + @MethodSource + void verify_truncatedPacket_returnsPacketTooShort(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + TestContext context = createContext(algorithm, stamper); + + int signatureSize = context.integrity.size(); + Assertions.assertTrue(signatureSize > 0); + + Packet truncated = new Packet(PACKET_CAPACITY, false); + truncated.put((byte) 0x01); + truncated.flip(); + truncated.limit(signatureSize - 1); + + VerificationResult result = context.integrity.verify(truncated); + + Assertions.assertFalse(result.isValid()); + Assertions.assertEquals(FailureReason.PACKET_TOO_SHORT, result.getReason()); + } + + private static Stream verify_happyPath_returnsOk() { + return parameters(); + } + + private static Stream verify_nullPacket_returnsPacketNull() { + return parameters(); + } + + private static Stream verify_tamperPayload_returnsSignatureMismatch() { + return parameters(); + } + + private static Stream verify_tamperSignature_returnsSignatureMismatch() { + return parameters(); + } + + private static Stream verify_truncatedPacket_returnsPacketTooShort() { + return parameters(); + } + + private static Stream parameters() { + List list = new ArrayList<>(); + for (String algorithm : PacketIntegrityFactory.getInstance().getAlgorithms()) { + list.add(arguments(algorithm, new PrependerSignatureManager())); + list.add(arguments(algorithm, new AppenderSignatureManager())); + } + return list.stream(); + } + + private static PacketIntegrity createIntegrity(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + Random random = new Random(1234567L); + byte[] key = new byte[100]; + random.nextBytes(key); + + return PacketIntegrityFactory.getInstance().getPacketIntegrity(algorithm, stamper, key); + } + + private static TestContext createContext(String algorithm, SignatureManager stamper) + throws NoSuchAlgorithmException, InvalidKeyException { + + PacketIntegrity integrity = createIntegrity(algorithm, stamper); + + Packet payload = createPayloadPacket(); + Packet secured = integrity.secure(clonePacket(payload)); + + return new TestContext(payload, integrity, secured); + } + + private static Packet createPayloadPacket() { + Packet packet = new Packet(PACKET_CAPACITY, false); + for (int index = 0; index < PAYLOAD_LENGTH; index++) { + packet.put((byte) (index & 0xFF)); + } + packet.flip(); + return packet; + } + + private static Packet clonePacket(Packet source) { + Packet clone = new Packet(source.capacity(), false); + + int limit = source.limit(); + for (int index = 0; index < limit; index++) { + clone.put(source.get(index)); + } + clone.flip(); + clone.limit(limit); + + return clone; + } + + private static void flipOnePayloadByte(Packet packet, int signatureSize, SignatureManager stamper) { + int payloadStart; + if (stamper instanceof PrependerSignatureManager) { + payloadStart = signatureSize; + } else { + payloadStart = 0; + } + + int payloadEnd; + if (stamper instanceof PrependerSignatureManager) { + payloadEnd = packet.limit(); + } else { + payloadEnd = packet.limit() - signatureSize; + } + + Assertions.assertTrue(payloadEnd > payloadStart, "No payload region available to tamper"); + + int indexToFlip = payloadStart + ((payloadEnd - payloadStart) / 2); + byte value = packet.get(indexToFlip); + packet.put(indexToFlip, (byte) (value ^ 0x01)); + } + + private static void flipOneSignatureByte(Packet packet, int signatureSize, SignatureManager stamper) { + Assertions.assertTrue(signatureSize > 0, "Signature size must be > 0"); + + int signatureStart; + if (stamper instanceof PrependerSignatureManager) { + signatureStart = 0; + } else { + signatureStart = packet.limit() - signatureSize; + } + + int indexToFlip = signatureStart + (signatureSize / 2); + byte value = packet.get(indexToFlip); + packet.put(indexToFlip, (byte) (value ^ 0x01)); + } + + private static class TestContext { + private final Packet payload; + private final PacketIntegrity integrity; + private final Packet secured; + + private TestContext(Packet payload, PacketIntegrity integrity, Packet secured) { + this.payload = payload; + this.integrity = integrity; + this.secured = secured; + } + } +} diff --git a/src/test/java/io/mapsmessaging/network/io/security/PacketValidationTests.java b/src/test/java/io/mapsmessaging/network/io/security/PacketValidationTests.java index 4767cd0a4..ee1b9dac1 100644 --- a/src/test/java/io/mapsmessaging/network/io/security/PacketValidationTests.java +++ b/src/test/java/io/mapsmessaging/network/io/security/PacketValidationTests.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/client/StandardClientTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/client/StandardClientTest.java index ffed71266..9df640d5a 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/client/StandardClientTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/client/StandardClientTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BaseConnection.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BaseConnection.java index f206fc8f5..0f8a9ad38 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BaseConnection.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BaseConnection.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BrowserConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BrowserConnectionTest.java index ce532c826..5f8211bf2 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BrowserConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/BrowserConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/FilteredSubscriptionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/FilteredSubscriptionTest.java index ea1b656d6..288e0ffba 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/FilteredSubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/FilteredSubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleConnectionTest.java index f8ef10953..a40000bbd 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleDurableConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleDurableConnectionTest.java index 393d21289..05e1e97bc 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleDurableConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleDurableConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleTransactionConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleTransactionConnectionTest.java index 130acc79f..656365a9f 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleTransactionConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/SimpleTransactionConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/TemporaryDestinationTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/TemporaryDestinationTest.java index 14e2ebc15..5143a6f04 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/TemporaryDestinationTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/TemporaryDestinationTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/Compliance.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/Compliance.java index 1f1ac2441..23c660ca8 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/Compliance.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/Compliance.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsAdministrator.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsAdministrator.java index 45314db58..a56afbb8d 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsAdministrator.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsAdministrator.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsProvider.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsProvider.java index 5523b8169..e3d8d9396 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsProvider.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/jmscts/JmsProvider.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BaseMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BaseMessageTypeTest.java index d68f1bb18..af8240ad5 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BaseMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BaseMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BytesMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BytesMessageTypeTest.java index 5c771ce2a..dd3bef180 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BytesMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/BytesMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MapMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MapMessageTypeTest.java index dbf9561d8..b1de3e96c 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MapMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MapMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MessageTypeTest.java index c46dc1a22..88b433e6c 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/MessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/ObjectMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/ObjectMessageTypeTest.java index 78c9b65b2..244a5ad3d 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/ObjectMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/ObjectMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/StreamMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/StreamMessageTypeTest.java index 8800a9048..29443bba0 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/StreamMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/StreamMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/TextMessageTypeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/TextMessageTypeTest.java index 2a4ae970a..bd891c697 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/TextMessageTypeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/amqp/jms/messages/TextMessageTypeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/canaerospace/CanAerospaceViaCanBusTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/canaerospace/CanAerospaceViaCanBusTest.java new file mode 100644 index 000000000..de7c8afaa --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/canaerospace/CanAerospaceViaCanBusTest.java @@ -0,0 +1,294 @@ +package io.mapsmessaging.network.protocol.impl.canaerospace; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.canbus.device.SocketCanDevice; +import io.mapsmessaging.canbus.device.frames.CanFrame; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class CanAerospaceViaCanBusTest extends BaseTestConfig { + + @Test + void testValidCanaerospaceFramesAreDecodedAndPublished() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch messageLatch = new CountDownLatch(2); + List receivedMessages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + String topicName = messageEvent.getDestinationName(); + receivedMessages.add(new ReceivedMessage(topicName, messageEvent.getMessage())); + messageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("canaerospaceValidFramesTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContext subscriptionContext = new SubscriptionContextBuilder("/vcan1/#", ClientAcknowledgement.AUTO) + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(subscriptionContext); + + delay(100); + + injectRawCanFramesIntoVcan("vcan1", new RawFrame[]{ + new RawFrame(0x137, false, "0106000000000064"), + new RawFrame(0x138, false, "01060000000000C8") + }); + + boolean received = messageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "Expected decoded CANAerospace messages to be published"); + Assertions.assertEquals(2, receivedCount.get(), "Expected one published message per injected valid CANAerospace frame"); + + session.removeSubscription(subscriptionContext.getKey()); + + int attempts = 0; + do { + delay(100); + attempts++; + } + while (receivedMessages.size() < 2 && attempts < 20); + + Assertions.assertEquals(2, receivedMessages.size(), "Expected exactly two published messages"); + + for (ReceivedMessage receivedMessage : receivedMessages) { + Assertions.assertFalse( + receivedMessage.topicName.endsWith("/unknown"), + "Valid CANAerospace frame should not be routed to unknown topic: " + receivedMessage.topicName + ); + validateDecodedCanaerospacePayload(receivedMessage.message); + } + } + finally { + close(session); + } + } + + @Test + void testUnknownEightByteFrameRoutesToUnknownTopic() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch messageLatch = new CountDownLatch(1); + List receivedMessages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + String topicName = messageEvent.getDestinationName(); + receivedMessages.add(new ReceivedMessage(topicName, messageEvent.getMessage())); + messageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("canaerospaceUnknownEightByteTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContext subscriptionContext = new SubscriptionContextBuilder("/vcan1/#", ClientAcknowledgement.AUTO) + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(subscriptionContext); + + delay(100); + + RawFrame unknownFrame = new RawFrame(0x18FF9999, true, "0102030405060708"); + injectRawCanFramesIntoVcan("vcan1", new RawFrame[]{unknownFrame}); + + boolean received = messageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "Expected unknown 8-byte frame to be published"); + Assertions.assertEquals(1, receivedCount.get(), "Expected one published message"); + + session.removeSubscription(subscriptionContext.getKey()); + + int attempts = 0; + do { + delay(100); + attempts++; + } + while (receivedMessages.size() < 1 && attempts < 20); + + Assertions.assertEquals(1, receivedMessages.size(), "Expected exactly one published message"); + + ReceivedMessage receivedMessage = receivedMessages.get(0); + Assertions.assertTrue( + receivedMessage.topicName.endsWith("/unknown"), + "Unknown CANAerospace frame should route to unknown topic: " + receivedMessage.topicName + ); + + validateUnknownPayload(receivedMessage.message, hexToBytes(unknownFrame.dataHex)); + } + finally { + close(session); + } + } + + private void validateDecodedCanaerospacePayload(Message message) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root; + try { + root = JsonParser.parseString(payload); + } + catch (Exception exception) { + Assertions.fail("Payload is not valid JSON. Payload: " + payload, exception); + return; + } + + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + assertFieldPresent(object, "canId"); + assertFieldPresent(object, "dlc"); + assertFieldPresent(object, "extended"); + assertFieldPresent(object, "data"); + assertFieldPresent(object, "canaerospace"); + + int dlc = object.get("dlc").getAsInt(); + Assertions.assertEquals(8, dlc, "Decoded CANAerospace payload must have dlc=8. Payload: " + payload); + + String data = object.get("data").getAsString(); + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(data); + } + catch (IllegalArgumentException exception) { + Assertions.fail("data is not valid Base64. Payload: " + payload, exception); + return; + } + + Assertions.assertEquals(8, decoded.length, "Decoded payload length must match dlc. Payload: " + payload); + + JsonObject canaerospace = object.getAsJsonObject("canaerospace"); + assertFieldPresent(canaerospace, "nodeId"); + assertFieldPresent(canaerospace, "payloadDataTypeNumber"); + assertFieldPresent(canaerospace, "serviceCode"); + assertFieldPresent(canaerospace, "messageCode"); + + int nodeId = canaerospace.get("nodeId").getAsInt(); + int payloadDataTypeNumber = canaerospace.get("payloadDataTypeNumber").getAsInt(); + int serviceCode = canaerospace.get("serviceCode").getAsInt(); + int messageCode = canaerospace.get("messageCode").getAsInt(); + + Assertions.assertTrue(nodeId >= 0 && nodeId <= 255, "nodeId must be in [0..255]. Payload: " + payload); + Assertions.assertTrue(payloadDataTypeNumber >= 0 && payloadDataTypeNumber <= 255, "payloadDataTypeNumber must be in [0..255]. Payload: " + payload); + Assertions.assertTrue(serviceCode >= 0 && serviceCode <= 255, "serviceCode must be in [0..255]. Payload: " + payload); + Assertions.assertTrue(messageCode >= 0 && messageCode <= 255, "messageCode must be in [0..255]. Payload: " + payload); + } + + private void validateUnknownPayload(Message message, byte[] expectedPayloadBytes) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root = JsonParser.parseString(payload); + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + assertFieldPresent(object, "canId"); + assertFieldPresent(object, "dlc"); + assertFieldPresent(object, "extended"); + assertFieldPresent(object, "data"); + + int dlc = object.get("dlc").getAsInt(); + String data = object.get("data").getAsString(); + + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(data); + } + catch (IllegalArgumentException exception) { + Assertions.fail("data is not valid Base64. Payload: " + payload, exception); + return; + } + + Assertions.assertEquals(dlc, decoded.length, "Decoded data length must match dlc. Payload: " + payload); + } + + private void assertFieldPresent(JsonObject object, String fieldName) { + Assertions.assertTrue(object.has(fieldName), "Missing field '" + fieldName + "'. Object: " + object); + Assertions.assertFalse(object.get(fieldName).isJsonNull(), "Field '" + fieldName + "' must not be null. Object: " + object); + } + + private void injectRawCanFramesIntoVcan(String interfaceName, RawFrame[] frames) throws IOException { + try (SocketCanDevice socketCanDevice = new SocketCanDevice(interfaceName)) { + for (RawFrame rawFrame : frames) { + byte[] payload = hexToBytes(rawFrame.dataHex); + Assertions.assertTrue(payload.length <= 8, "Raw frame payload must be <= 8 bytes"); + + CanFrame frame = new CanFrame(rawFrame.canId, rawFrame.extended, payload.length, payload); + socketCanDevice.writeFrame(frame); + delay(50); + } + } + } + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + byte[] bytes = new byte[length / 2]; + + for (int index = 0; index < length; index += 2) { + int value = Integer.parseInt(hex.substring(index, index + 2), 16); + bytes[index / 2] = (byte) value; + } + + return bytes; + } + + private static final class ReceivedMessage { + private final String topicName; + private final Message message; + + private ReceivedMessage(String topicName, Message message) { + this.topicName = topicName; + this.message = message; + } + } + + private static final class RawFrame { + private final int canId; + private final boolean extended; + private final String dataHex; + + private RawFrame(int canId, boolean extended, String dataHex) { + this.canId = canId; + this.extended = extended; + this.dataHex = dataHex; + } + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BaseCoapTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BaseCoapTest.java index 3aace8181..db3644544 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BaseCoapTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BaseCoapTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseReceiveTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseReceiveTest.java index 6352d35a5..79682b02a 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseReceiveTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseReceiveTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseSendTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseSendTest.java index c72439259..6395e4a96 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseSendTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/BlockwiseSendTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapObserverTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapObserverTest.java index 969722a3f..e60469db6 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapObserverTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapObserverTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapSimpleInteractionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapSimpleInteractionTest.java index 9f73aae39..4fdcbb4c6 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapSimpleInteractionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/CoapSimpleInteractionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/DtlsTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/DtlsTest.java index 764f4d6fb..6b4b91d69 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/coap/DtlsTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/coap/DtlsTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoConformance.java b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoConformance.java index f0f1e81ca..953086279 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoConformance.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoConformance.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT3ConformanceIT.java b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT3ConformanceIT.java index 67d05cd55..a25425ae2 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT3ConformanceIT.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT3ConformanceIT.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT5ConformanceIT.java b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT5ConformanceIT.java index c0ff669b0..b66208bbf 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT5ConformanceIT.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/conformance/PahoMQTT5ConformanceIT.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoClient.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoClient.java index d96d01505..322f33b76 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoClient.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoClient.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoConnectionTest.java index d68528d1e..932cd882e 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoPublishMessagesTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoPublishMessagesTest.java index c6029bb34..52b3ef851 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoPublishMessagesTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoPublishMessagesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLConnectionTest.java index 2394a987d..b8c202c38 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLPublishMessagesTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLPublishMessagesTest.java index 5cb990411..22bcf9892 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLPublishMessagesTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/EchoSSLPublishMessagesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/SSLEchoClient.java b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/SSLEchoClient.java index 146d85348..111e16df1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/echo/SSLEchoClient.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/echo/SSLEchoClient.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/local/LoopbackEngineExecutorTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/local/LoopbackEngineExecutorTest.java new file mode 100644 index 000000000..bdbf9766b --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/local/LoopbackEngineExecutorTest.java @@ -0,0 +1,172 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.local; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class LoopbackEngineExecutorTest { + + @Test + void submitRunsOnDifferentThread() throws Exception { + LoopbackEngineExecutor executor = + new LoopbackEngineExecutor(1, 32, "test-loopback", false); + + try { + long callerThreadId = Thread.currentThread().getId(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference executionThreadId = new AtomicReference<>(); + + boolean accepted = executor.submit(() -> { + executionThreadId.set(Thread.currentThread().getId()); + latch.countDown(); + }); + + assertTrue(accepted); + assertTrue(latch.await(2, TimeUnit.SECONDS)); + assertNotNull(executionThreadId.get()); + assertNotEquals(callerThreadId, executionThreadId.get()); + assertTrue(executor.awaitIdle(2, TimeUnit.SECONDS)); + assertEquals(1L, executor.getExecutedCount()); + } finally { + executor.shutdown(); + } + } + + @Test + void awaitIdleWaitsForAllTasks() throws Exception { + int poolSize = 2; + LoopbackEngineExecutor executor = + new LoopbackEngineExecutor(poolSize, 128, "test-loopback", false); + + try { + int taskCount = 100; + + CountDownLatch firstWorkersStarted = new CountDownLatch(poolSize); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger ran = new AtomicInteger(); + + for (int i = 0; i < taskCount; i++) { + boolean accepted = executor.submit(() -> { + firstWorkersStarted.countDown(); + try { + release.await(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + ran.incrementAndGet(); + }); + assertTrue(accepted); + } + + assertTrue(firstWorkersStarted.await(2, TimeUnit.SECONDS)); + release.countDown(); + + assertTrue(executor.awaitIdle(5, TimeUnit.SECONDS)); + assertEquals(taskCount, ran.get()); + assertEquals(taskCount, executor.getExecutedCount()); + assertEquals(0L, executor.getRejectedCount()); + } finally { + executor.shutdown(); + } + } + + @Test + void rejectsWhenQueueIsFull() throws Exception { + LoopbackEngineExecutor executor = new LoopbackEngineExecutor(1, 1, "test-loopback", false); + try { + CountDownLatch task1Started = new CountDownLatch(1); + CountDownLatch holdWorker = new CountDownLatch(1); + boolean accepted1 = executor.submit(() -> { + task1Started.countDown(); + try { + holdWorker.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(accepted1); + assertTrue(task1Started.await(2, TimeUnit.SECONDS)); + + CountDownLatch task2Created = new CountDownLatch(1); + boolean accepted2 = executor.submit(task2Created::countDown); + assertTrue(accepted2); + + assertTrue(waitForQueueSize(executor, 1, 2, TimeUnit.SECONDS)); + + boolean accepted3 = executor.submit(() -> { }); + assertFalse(accepted3); + assertTrue(executor.getRejectedCount() >= 1L); + + holdWorker.countDown(); + + assertTrue(executor.awaitIdle(3, TimeUnit.SECONDS)); + assertTrue(executor.getExecutedCount() >= 2L); + } finally { + executor.shutdown(); + } + } + + private static boolean waitForQueueSize(LoopbackEngineExecutor executor, + int expectedSize, + long timeout, + TimeUnit unit) throws InterruptedException { + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + while (System.nanoTime() < deadlineNanos) { + if (executor.getQueueSize() == expectedSize) { + return true; + } + Thread.yield(); + } + return executor.getQueueSize() == expectedSize; + } + + @Test + void exceptionDoesNotKillExecutorThreads() throws Exception { + LoopbackEngineExecutor executor = + new LoopbackEngineExecutor(1, 32, "test-loopback", false); + + try { + CountDownLatch okLatch = new CountDownLatch(1); + + boolean accepted1 = executor.submit(() -> { + throw new RuntimeException("boom"); + }); + assertTrue(accepted1); + + boolean accepted2 = executor.submit(okLatch::countDown); + assertTrue(accepted2); + + assertTrue(okLatch.await(2, TimeUnit.SECONDS)); + assertTrue(executor.awaitIdle(2, TimeUnit.SECONDS)); + + assertTrue(executor.getFailedCount() >= 1L); + assertTrue(executor.getExecutedCount() >= 2L); + } finally { + executor.shutdown(); + } + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/GsonFactoryTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/GsonFactoryTest.java new file mode 100644 index 000000000..144d673c9 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/GsonFactoryTest.java @@ -0,0 +1,118 @@ +package io.mapsmessaging.network.protocol.impl.mavlink; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class GsonFactoryTest { + + private final Gson gson = GsonFactory.createStrictJsonWithSafeFloats(); + + @Test + void write_nonFiniteBoxedValues_areOmitted_notSerializedAsNaNOrInfinity() { + BoxedNumbers data = new BoxedNumbers(); + data.doubleValue = Double.NaN; + data.floatValue = Float.POSITIVE_INFINITY; + + String json = gson.toJson(data); + + assertEquals("{}", json); + assertFalse(json.contains("NaN"), json); + assertFalse(json.contains("Infinity"), json); + } + + @Test + void write_nonFinitePrimitiveValues_areOmitted_notSerializedAsNaNOrInfinity() { + PrimitiveNumbers data = new PrimitiveNumbers(); + data.doubleValue = Double.NEGATIVE_INFINITY; + data.floatValue = Float.NaN; + + String json = gson.toJson(data); + + assertEquals("{}", json); + assertFalse(json.contains("NaN"), json); + assertFalse(json.contains("Infinity"), json); + } + + @Test + void write_mixedFiniteAndNonFinite_onlyFiniteSurvives() { + BoxedNumbers data = new BoxedNumbers(); + data.doubleValue = 12.5; + data.floatValue = Float.NaN; + + String json = gson.toJson(data); + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + + assertTrue(obj.has("doubleValue"), json); + assertEquals(12.5, obj.get("doubleValue").getAsDouble(), 0.0); + + assertFalse(obj.has("floatValue"), json); + assertFalse(json.contains("NaN"), json); + assertFalse(json.contains("Infinity"), json); + } + + @Test + void write_finiteValues_arePreserved() { + BoxedNumbers boxed = new BoxedNumbers(); + boxed.doubleValue = 12.5; + boxed.floatValue = 3.25f; + + PrimitiveNumbers prim = new PrimitiveNumbers(); + prim.doubleValue = -99.125; + prim.floatValue = 0.5f; + + String jsonBoxed = gson.toJson(boxed); + String jsonPrim = gson.toJson(prim); + + JsonObject objBoxed = JsonParser.parseString(jsonBoxed).getAsJsonObject(); + assertEquals(12.5, objBoxed.get("doubleValue").getAsDouble(), 0.0); + assertEquals(3.25f, objBoxed.get("floatValue").getAsFloat(), 0.0001f); + + JsonObject objPrim = JsonParser.parseString(jsonPrim).getAsJsonObject(); + assertEquals(-99.125, objPrim.get("doubleValue").getAsDouble(), 0.0); + assertEquals(0.5f, objPrim.get("floatValue").getAsFloat(), 0.0001f); + } + + @Test + void write_nonFinite_directBoxedSerializesAsNull_provesAdapterIsActive() { + assertEquals("null", gson.toJson(Double.NaN, Double.class)); + assertEquals("null", gson.toJson(Double.POSITIVE_INFINITY, Double.class)); + assertEquals("null", gson.toJson(Float.NaN, Float.class)); + assertEquals("null", gson.toJson(Float.NEGATIVE_INFINITY, Float.class)); + } + + @Test + void read_stringNaNAndInfinity_mapToNull_forBoxed() { + BoxedNumbers data = gson.fromJson("{\"doubleValue\":\"NaN\",\"floatValue\":\"Infinity\"}", BoxedNumbers.class); + assertNull(data.doubleValue); + assertNull(data.floatValue); + + BoxedNumbers data2 = gson.fromJson("{\"doubleValue\":\"-Infinity\",\"floatValue\":\"-Infinity\"}", BoxedNumbers.class); + assertNull(data2.doubleValue); + assertNull(data2.floatValue); + } + + @Test + void read_numericValues_parse_forBoxedAndPrimitive() { + BoxedNumbers boxed = gson.fromJson("{\"doubleValue\":1.25,\"floatValue\":2.5}", BoxedNumbers.class); + assertEquals(1.25, boxed.doubleValue, 0.0); + assertEquals(2.5f, boxed.floatValue, 0.0001f); + + PrimitiveNumbers prim = gson.fromJson("{\"doubleValue\":-7.0,\"floatValue\":0.125}", PrimitiveNumbers.class); + assertEquals(-7.0, prim.doubleValue, 0.0); + assertEquals(0.125f, prim.floatValue, 0.0001f); + } + + public static final class BoxedNumbers { + public Double doubleValue; + public Float floatValue; + } + + public static final class PrimitiveNumbers { + public double doubleValue; + public float floatValue; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerHangTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerHangTest.java new file mode 100644 index 000000000..731adc02b --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerHangTest.java @@ -0,0 +1,155 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.mavlink; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class MavlinkStreamHandlerHangTest { + + @Test + void parseInput_canHangForever_ifInputStreamReturnsZeroLengthReads() { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v1 = buildV1Frame( + 1, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 0, + (byte) 0x12, (byte) 0x34 + ); + + InputStream input = new ZeroReadBulkInputStream(v1); + Packet packet = new Packet(256, false); + + assertThrows(AssertionFailedError.class, () -> + assertTimeoutPreemptively(Duration.ofMillis(200), () -> { + handler.parseInput(input, packet); + }) + ); + } + + @Test + void parseInput_timesOut_afterConfiguredReadTimeout() { + int timeoutMillis = 5000; + MavlinkStreamHandler handler = new MavlinkStreamHandler(timeoutMillis); + + byte[] frame = buildV1Frame( + 10, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, + (byte) 0x12, (byte) 0x34 + ); + + InputStream input = new ZeroReadBulkInputStream(frame); + Packet packet = new Packet(256, false); + + long start = System.nanoTime(); + + IOException ex = assertThrows(IOException.class, () -> { + handler.parseInput(input, packet); + }); + + long elapsedMillis = (System.nanoTime() - start) / 1_000_000L; + + assertTrue( + elapsedMillis >= timeoutMillis, + "Elapsed " + elapsedMillis + "ms < timeout " + timeoutMillis + "ms" + ); + + assertTrue( + elapsedMillis < timeoutMillis + 1000, + "Elapsed " + elapsedMillis + "ms exceeded expected timeout window" + ); + + assertTrue( + ex.getMessage().toLowerCase().contains("timed out"), + "Expected timeout IOException, got: " + ex.getMessage() + ); + } + + private static final class ZeroReadBulkInputStream extends InputStream { + + private final ByteArrayInputStream delegate; + + private ZeroReadBulkInputStream(byte[] data) { + this.delegate = new ByteArrayInputStream(data); + } + + @Override + public int read() { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + // This is the whole point: read(byte[],off,len) returns 0 without EOF. + // Your readFully() currently spins forever on this. + return 0; + } + } + + private static byte[] buildV1Frame(int payloadLength, byte seq, byte sysId, byte compId, byte msgId, byte crcExtra, byte... payloadAndCrc) { + byte[] payload; + byte[] crc; + + if (payloadLength == 0) { + payload = new byte[0]; + crc = new byte[]{0, 0}; + } else { + if (payloadAndCrc.length < payloadLength + 2) { + payload = new byte[payloadLength]; + for (int i = 0; i < payloadLength; i++) { + payload[i] = (byte) (0xA0 + i); + } + crc = new byte[]{(byte) 0x12, (byte) 0x34}; + } else { + payload = Arrays.copyOfRange(payloadAndCrc, 0, payloadLength); + crc = Arrays.copyOfRange(payloadAndCrc, payloadLength, payloadLength + 2); + } + } + + int length = 2 + 5 + payloadLength + 2; + + byte[] frame = new byte[length]; + int idx = 0; + + frame[idx++] = (byte) 0xFE; // magic + frame[idx++] = (byte) (payloadLength & 0xFF); // len + frame[idx++] = seq; // seq + frame[idx++] = sysId; // sysid + frame[idx++] = compId; // compid + frame[idx++] = msgId; // msgid + frame[idx++] = crcExtra; // "extra" (your code treats as header byte) + + System.arraycopy(payload, 0, frame, idx, payload.length); + idx += payload.length; + + System.arraycopy(crc, 0, frame, idx, crc.length); + + return frame; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerTest.java new file mode 100644 index 000000000..fdcb5ea93 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkStreamHandlerTest.java @@ -0,0 +1,417 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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. + */ + +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.mavlink; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class MavlinkStreamHandlerTest { + + private static final int MAVLINK_V1_MAGIC = 0xFE; + private static final int MAVLINK_V2_MAGIC = 0xFD; + + private static final int MAVLINK_V1_HEADER_REST = 5; + private static final int MAVLINK_V1_CRC_LEN = 2; + + private static final int MAVLINK_V2_HEADER_REST = 8; + private static final int MAVLINK_V2_CRC_LEN = 2; + private static final int MAVLINK_V2_SIGNATURE_LEN = 13; + private static final int MAVLINK_V2_INCOMPAT_FLAG_SIGNED = 0x01; + + @Test + void parseInput_skipsNoiseUntilMagic_andDoesNotSkipFrames() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v1 = buildV1Frame( + 4, + (byte) 10, (byte) 11, (byte) 12, (byte) 13, + (byte) 1, (byte) 2 + ); + + byte[] v2 = buildV2Frame( + 3, + false, + (byte) 21, (byte) 22, (byte) 23, + (byte) 4, (byte) 5 + ); + + byte[] noise = new byte[]{0x00, 0x7E, 0x55, (byte) 0xAA, 0x33}; + + byte[] stream = concat(noise, v1, v2); + ByteArrayInputStream input = new ByteArrayInputStream(stream); + + TestPacket packet1 = new TestPacket(512); + int len1 = handler.parseInput(input, packet1); + assertEquals(v1.length, len1); + assertArrayEquals(v1, packet1.toByteArrayExact()); + + TestPacket packet2 = new TestPacket(512); + int len2 = handler.parseInput(input, packet2); + assertEquals(v2.length, len2); + assertArrayEquals(v2, packet2.toByteArrayExact()); + } + + @Test + void parseInput_v2Signed_readsSignatureWhenFlagSet() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v2Signed = buildV2Frame( + 6, + true, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, + (byte) 9, (byte) 10 + ); + + ByteArrayInputStream input = new ByteArrayInputStream(v2Signed); + TestPacket packet = new TestPacket(512); + + int len = handler.parseInput(input, packet); + assertEquals(v2Signed.length, len); + assertArrayEquals(v2Signed, packet.toByteArrayExact()); + } + + @Test + void parseInput_v2Unsigned_doesNotTryToReadSignature() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v2Unsigned = buildV2Frame( + 0, + false + ); + + ByteArrayInputStream input = new ByteArrayInputStream(v2Unsigned); + TestPacket packet = new TestPacket(128); + + int len = handler.parseInput(input, packet); + assertEquals(v2Unsigned.length, len); + assertArrayEquals(v2Unsigned, packet.toByteArrayExact()); + + assertEquals(0, input.available()); + } + + @Test + void parseInput_truncatedHeader_throwsUnexpectedEnd() { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] truncated = new byte[]{ + (byte) MAVLINK_V1_MAGIC, + 10, + 1, 2 + }; + + ByteArrayInputStream input = new ByteArrayInputStream(truncated); + TestPacket packet = new TestPacket(256); + + IOException ex = assertThrows(IOException.class, () -> handler.parseInput(input, packet)); + assertTrue(ex.getMessage().toLowerCase().contains("unexpected end")); + } + + @Test + void parseInput_truncatedPayload_throwsUnexpectedEnd() { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] frame = buildV1Frame( + 10, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, + (byte) 9, (byte) 9 + ); + + byte[] truncated = Arrays.copyOf(frame, frame.length - 5); + + ByteArrayInputStream input = new ByteArrayInputStream(truncated); + TestPacket packet = new TestPacket(256); + + IOException ex = assertThrows(IOException.class, () -> handler.parseInput(input, packet)); + assertTrue(ex.getMessage().toLowerCase().contains("unexpected end")); + } + + @Test + void parseInput_capacityTooSmall_throws() { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v2Signed = buildV2Frame( + 20, + true, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9, (byte) 10, + (byte) 0x11, (byte) 0x22 + ); + + ByteArrayInputStream input = new ByteArrayInputStream(v2Signed); + TestPacket packet = new TestPacket(16); + + IOException ex = assertThrows(IOException.class, () -> handler.parseInput(input, packet)); + assertTrue(ex.getMessage().toLowerCase().contains("smaller than required")); + } + + @Test + void parseOutput_writesAllBytes_afterFlip() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v1 = buildV1Frame( + 5, + (byte) 10, (byte) 11, (byte) 12, (byte) 13, (byte) 14, + (byte) 1, (byte) 2 + ); + + Packet packet = new Packet(128, false); + packet.clear(); + packet.put(v1, 0, v1.length); + packet.flip(); // critical with your Packet/ByteBuffer contract + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int written = handler.parseOutput(out, packet); + + assertEquals(v1.length, written); + assertArrayEquals(v1, out.toByteArray()); + } + @Test + void parseOutput_zeroLength_returnsZero() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + Packet packet = new Packet(64, false); + packet.clear(); + packet.limit(0); // empty: position=0, limit=0 (since clear() makes limit=capacity) + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int written = handler.parseOutput(out, packet); + + assertEquals(0, written); + assertEquals(0, out.size()); + } + @Test + void parseInput_handlesChunkedInputStream_withoutSkipping() throws Exception { + MavlinkStreamHandler handler = new MavlinkStreamHandler(); + + byte[] v1 = buildV1Frame( + 20, + (byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, + (byte) 0x12, (byte) 0x34 + ); + + byte[] v2 = buildV2Frame( + 25, + true, + (byte) 0x55, (byte) 0x66 + ); + + byte[] stream = concat(v1, v2); + + ByteArrayInputStream base = new ByteArrayInputStream(stream); + InputStream chunked = new ChunkedInputStream(base, 3); // max 3 bytes per read + + Packet packet1 = new Packet(512, false); + int len1 = handler.parseInput(chunked, packet1); + assertEquals(v1.length, len1); + packet1.flip(); + assertArrayEquals(v1, toByteArray(packet1)); + + Packet packet2 = new Packet(512, false); + int len2 = handler.parseInput(chunked, packet2); + assertEquals(v2.length, len2); + packet2.flip(); + assertArrayEquals(v2, toByteArray(packet2)); + } + + private static byte[] toByteArray(Packet packet) { + ByteBuffer dup = packet.getRawBuffer().duplicate(); + byte[] out = new byte[dup.remaining()]; + dup.get(out); + return out; + } + + private static final class ChunkedInputStream extends InputStream { + + private final InputStream delegate; + private final int maxPerRead; + + private ChunkedInputStream(InputStream delegate, int maxPerRead) { + this.delegate = delegate; + this.maxPerRead = maxPerRead; + } + + @Override + public int read() throws IOException { + return delegate.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int capped = Math.min(len, maxPerRead); + return delegate.read(b, off, capped); + } + } + + + private static byte[] buildV1Frame(int payloadLength, byte seq, byte sysId, byte compId, byte msgId, byte... payloadAndCrc) { + byte[] payload; + byte[] crc; + if (payloadLength == 0) { + payload = new byte[0]; + crc = new byte[]{0, 0}; + } else { + if (payloadAndCrc.length < payloadLength + 2) { + payload = new byte[payloadLength]; + for (int i = 0; i < payloadLength; i++) { + payload[i] = (byte) (0xA0 + i); + } + crc = new byte[]{(byte) 0x12, (byte) 0x34}; + } else { + payload = Arrays.copyOfRange(payloadAndCrc, 0, payloadLength); + crc = Arrays.copyOfRange(payloadAndCrc, payloadLength, payloadLength + 2); + } + } + + int length = 2 + MAVLINK_V1_HEADER_REST + payloadLength + MAVLINK_V1_CRC_LEN; + ByteBuffer bb = ByteBuffer.allocate(length); + + bb.put((byte) MAVLINK_V1_MAGIC); + bb.put((byte) payloadLength); + + bb.put(seq); + bb.put(sysId); + bb.put(compId); + bb.put(msgId); + bb.put((byte) 0x00); + + bb.put(payload); + bb.put(crc); + + return bb.array(); + } + + private static byte[] buildV2Frame(int payloadLength, boolean signed, byte... payloadAndCrc) { + byte incompatFlags = (byte) (signed ? MAVLINK_V2_INCOMPAT_FLAG_SIGNED : 0x00); + byte compatFlags = 0x00; + byte seq = 0x01; + byte sysId = 0x02; + byte compId = 0x03; + byte msgId0 = 0x11; + byte msgId1 = 0x22; + byte msgId2 = 0x33; + + byte[] payload; + byte[] crc; + if (payloadLength == 0) { + payload = new byte[0]; + crc = new byte[]{0, 0}; + } else { + if (payloadAndCrc.length < payloadLength + 2) { + payload = new byte[payloadLength]; + for (int i = 0; i < payloadLength; i++) { + payload[i] = (byte) (0xB0 + i); + } + crc = new byte[]{(byte) 0x55, (byte) 0x66}; + } else { + payload = Arrays.copyOfRange(payloadAndCrc, 0, payloadLength); + crc = Arrays.copyOfRange(payloadAndCrc, payloadLength, payloadLength + 2); + } + } + + byte[] signature = new byte[0]; + if (signed) { + signature = new byte[MAVLINK_V2_SIGNATURE_LEN]; + for (int i = 0; i < signature.length; i++) { + signature[i] = (byte) (0xC0 + i); + } + } + + int length = 2 + MAVLINK_V2_HEADER_REST + payloadLength + MAVLINK_V2_CRC_LEN + signature.length; + ByteBuffer bb = ByteBuffer.allocate(length); + + bb.put((byte) MAVLINK_V2_MAGIC); + bb.put((byte) payloadLength); + + bb.put(incompatFlags); + bb.put(compatFlags); + bb.put(seq); + bb.put(sysId); + bb.put(compId); + bb.put(msgId0); + bb.put(msgId1); + bb.put(msgId2); + + bb.put(payload); + bb.put(crc); + bb.put(signature); + + return bb.array(); + } + + private static byte[] concat(byte[]... parts) { + int total = 0; + for (byte[] p : parts) { + total += p.length; + } + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } + return out; + } + + /** + * Adjust this to match your real Packet API if needed. + * The handler only needs: clear(), capacity(), putByte(int), put(byte[],off,len), getRawBuffer(). + */ + public static final class TestPacket extends Packet { + + public TestPacket(int capacity) { + super(capacity, false); + } + + public byte[] toByteArrayExact() { + ByteBuffer dup = getRawBuffer().duplicate(); + dup.flip(); + byte[] out = new byte[dup.remaining()]; + dup.get(out); + return out; + } + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkViaUdpTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkViaUdpTest.java new file mode 100644 index 000000000..27c1c3cfc --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mavlink/MavlinkViaUdpTest.java @@ -0,0 +1,289 @@ +package io.mapsmessaging.network.protocol.impl.mavlink; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class MavlinkViaUdpTest extends BaseTestConfig { + + /** + * Adjust this to whatever namespace/topic your MAVLink UDP listener publishes to. + * Examples you might be using: + * - "/udp/mavlink/#" + * - "/mavlink/#" + * - "/udp/14550/#" + */ + private static final String SUBSCRIBE_TOPIC = "/mavlink/#"; + + private static final String UDP_HOST = "127.0.0.1"; + private static final int UDP_PORT = 14550; + + @Test + void testSimpleSendHeartbeatOverUdp() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch firstMessageLatch = new CountDownLatch(1); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + messages.add(messageEvent.getMessage()); + firstMessageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("mavlinkUdpSimpleTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = + new SubscriptionContextBuilder(SUBSCRIBE_TOPIC, ClientAcknowledgement.AUTO); + + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + + byte[] heartbeat = buildMavlinkV1HeartbeatFrame( + 1, // seq + 1, // sysid + 1 // compid + ); + + sendUdpDatagram(UDP_HOST, UDP_PORT, heartbeat); + + boolean received = firstMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No MAVLink messages were published after UDP injection to port " + UDP_PORT); + Assertions.assertTrue(receivedCount.get() > 0, "Expected at least one message after UDP injection"); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (messages.isEmpty() && attempts < 20); + + session.removeSubscription(context.getKey()); + + Assertions.assertEquals(1, messages.size(), "Expected exactly one message after single heartbeat injection"); + validateMavlinkJsonPayload(messages.getFirst(), heartbeat); + } finally { + close(session); + } + } + + private void validateMavlinkJsonPayload(Message message, byte[] expectedBinary) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root; + try { + root = JsonParser.parseString(payload); + } catch (Exception e) { + Assertions.fail("Payload is not valid JSON. Payload: " + payload, e); + return; + } + + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + assertFieldPresent(object, "mavlink"); + Assertions.assertTrue(object.get("mavlink").isJsonObject(), "mavlink must be an object. Payload: " + payload); + JsonObject mavlink = object.getAsJsonObject("mavlink"); + + assertFieldPresent(mavlink, "version"); + assertFieldPresent(mavlink, "messageId"); + assertFieldPresent(mavlink, "systemId"); + assertFieldPresent(mavlink, "componentId"); + assertFieldPresent(mavlink, "sequence"); + assertFieldPresent(mavlink, "payloadLength"); + assertFieldPresent(mavlink, "signed"); + assertFieldPresent(mavlink, "payload"); + + Assertions.assertEquals("V1", mavlink.get("version").getAsString(), "Expected MAVLink V1. Payload: " + payload); + Assertions.assertEquals(0, mavlink.get("messageId").getAsInt(), "Expected HEARTBEAT msgId=0. Payload: " + payload); + Assertions.assertEquals(1, mavlink.get("systemId").getAsInt(), "systemId mismatch. Payload: " + payload); + Assertions.assertEquals(1, mavlink.get("componentId").getAsInt(), "componentId mismatch. Payload: " + payload); + Assertions.assertTrue(mavlink.get("sequence").getAsInt() >= 0 && mavlink.get("sequence").getAsInt() <= 255, "sequence must be [0..255]. Payload: " + payload); + Assertions.assertEquals(9, mavlink.get("payloadLength").getAsInt(), "payloadLength mismatch. Payload: " + payload); + Assertions.assertFalse(mavlink.get("signed").getAsBoolean(), "V1 heartbeat should not be signed. Payload: " + payload); + + JsonObject payloadObject = mavlink.getAsJsonObject("payload"); + assertFieldPresent(payloadObject, "rawBase64"); + + String rawBase64 = payloadObject.get("rawBase64").getAsString(); + Assertions.assertFalse(rawBase64.isBlank(), "rawBase64 must not be blank. Payload: " + payload); + + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(rawBase64); + } catch (IllegalArgumentException e) { + Assertions.fail("rawBase64 is not valid Base64. rawBase64=" + rawBase64 + " Payload: " + payload, e); + return; + } + + // Your JSON says payloadLength=9, so rawBase64 should decode to 9 bytes. + Assertions.assertEquals(9, decoded.length, "Decoded rawBase64 length must match payloadLength. Payload: " + payload); + + // Optional but useful: check the decoded payload bytes match what we injected. + // expectedBinary is the full MAVLink frame; payload bytes start after 6 header bytes for v1. + byte[] expectedPayload = extractMavlinkV1Payload(expectedBinary); + Assertions.assertArrayEquals(expectedPayload, decoded, "Decoded MAVLink payload does not match injected frame payload. Payload: " + payload); + + // Validate "decoded" fields if present + if (payloadObject.has("decoded") && payloadObject.get("decoded").isJsonObject()) { + JsonObject decodedObject = payloadObject.getAsJsonObject("decoded"); + + Assertions.assertEquals(3, decodedObject.get("mavlink_version").getAsInt(), "mavlink_version mismatch"); + Assertions.assertEquals(0, decodedObject.get("autopilot").getAsInt(), "autopilot mismatch"); + Assertions.assertEquals(0, decodedObject.get("system_status").getAsInt(), "system_status mismatch"); + Assertions.assertEquals(0, decodedObject.get("custom_mode").getAsInt(), "custom_mode mismatch"); + Assertions.assertEquals(0, decodedObject.get("base_mode").getAsInt(), "base_mode mismatch"); + Assertions.assertEquals(0, decodedObject.get("type").getAsInt(), "type mismatch"); + } + } + + private byte[] extractMavlinkV1Payload(byte[] mavlinkFrame) { + Assertions.assertTrue(mavlinkFrame.length >= 6, "MAVLink frame too short"); + int magic = mavlinkFrame[0] & 0xFF; + Assertions.assertEquals(0xFE, magic, "Expected MAVLink v1 magic 0xFE"); + int payloadLength = mavlinkFrame[1] & 0xFF; + Assertions.assertTrue(mavlinkFrame.length >= 6 + payloadLength + 2, "MAVLink frame length does not match header payload length"); + + byte[] payload = new byte[payloadLength]; + System.arraycopy(mavlinkFrame, 6, payload, 0, payloadLength); + return payload; + } + + + private void sendUdpDatagram(String host, int port, byte[] payload) throws IOException { + InetAddress address = InetAddress.getByName(host); + DatagramPacket packet = new DatagramPacket(payload, payload.length, address, port); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.send(packet); + } + } + + /** + * MAVLink v1 HEARTBEAT message (msgid 0), payload length 9. + * + * Frame format: + * magic(0xFE), len, seq, sysid, compid, msgid, payload[9], checksum[2] + * + * CRC is X25 over: len..payload plus "CRC extra" byte for that msg type. + * For HEARTBEAT, CRC extra is 50 (0x32). + */ + private byte[] buildMavlinkV1HeartbeatFrame(int sequence, int systemId, int componentId) { + int payloadLength = 9; + int magic = 0xFE; + int msgId = 0; // HEARTBEAT + + byte[] payload = new byte[payloadLength]; + + // custom_mode (uint32 little-endian) + payload[0] = 0x00; + payload[1] = 0x00; + payload[2] = 0x00; + payload[3] = 0x00; + + // type (uint8) - MAV_TYPE_GENERIC(0) is fine + payload[4] = 0x00; + + // autopilot (uint8) - MAV_AUTOPILOT_GENERIC(0) + payload[5] = 0x00; + + // base_mode (uint8) + payload[6] = 0x00; + + // system_status (uint8) + payload[7] = 0x00; + + // mavlink_version (uint8) typically 3 + payload[8] = 0x03; + + byte[] frame = new byte[6 + payloadLength + 2]; + + frame[0] = (byte) magic; + frame[1] = (byte) payloadLength; + frame[2] = (byte) (sequence & 0xFF); + frame[3] = (byte) (systemId & 0xFF); + frame[4] = (byte) (componentId & 0xFF); + frame[5] = (byte) (msgId & 0xFF); + + System.arraycopy(payload, 0, frame, 6, payloadLength); + + int crc = 0xFFFF; + + crc = x25CrcAccumulate((byte) payloadLength, crc); + crc = x25CrcAccumulate((byte) (sequence & 0xFF), crc); + crc = x25CrcAccumulate((byte) (systemId & 0xFF), crc); + crc = x25CrcAccumulate((byte) (componentId & 0xFF), crc); + crc = x25CrcAccumulate((byte) (msgId & 0xFF), crc); + + for (int i = 0; i < payloadLength; i++) { + crc = x25CrcAccumulate(payload[i], crc); + } + + // CRC extra for HEARTBEAT is 50 + crc = x25CrcAccumulate((byte) 50, crc); + + frame[6 + payloadLength] = (byte) (crc & 0xFF); + frame[6 + payloadLength + 1] = (byte) ((crc >> 8) & 0xFF); + + return frame; + } + + private int x25CrcAccumulate(byte input, int crc) { + int tmp = (input ^ (crc & 0xFF)) & 0xFF; + tmp = (tmp ^ ((tmp << 4) & 0xFF)) & 0xFF; + int result = ((crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)) & 0xFFFF; + return result; + } + + private void assertFieldPresent(JsonObject object, String fieldName) { + Assertions.assertTrue(object.has(fieldName), "Missing field '" + fieldName + "'. Object: " + object); + Assertions.assertFalse(object.get(fieldName).isJsonNull(), "Field '" + fieldName + "' must not be null. Object: " + object); + } + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + byte[] bytes = new byte[length / 2]; + + for (int i = 0; i < length; i += 2) { + int value = Integer.parseInt(hex.substring(i, i + 2), 16); + bytes[i / 2] = (byte) value; + } + + return bytes; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTBaseTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTBaseTest.java index cd0efc9b0..3d4edce9f 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTBaseTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTBaseTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTConnectionTest.java index 37111a5a7..564564ae3 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTPublishEventTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTPublishEventTest.java index e6367be6c..892157ff8 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTPublishEventTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTPublishEventTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTStoredMessageTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTStoredMessageTest.java index 940bb2dea..5dd1a17e8 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTStoredMessageTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTStoredMessageTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTSubscriptionImplTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTSubscriptionImplTest.java index 2045e8875..233366411 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTSubscriptionImplTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTSubscriptionImplTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTWildCardSubscriptionImplTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTWildCardSubscriptionImplTest.java index 124b8fa04..be4b822fb 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTWildCardSubscriptionImplTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/MQTTWildCardSubscriptionImplTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/QueueSubscriptionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/QueueSubscriptionTest.java index db5d61ff8..3ee821d74 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/QueueSubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/QueueSubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/ServerTopicTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/ServerTopicTest.java index 25e81846b..aa74808a0 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/ServerTopicTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/ServerTopicTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleBufferBasedMQTTIT.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleBufferBasedMQTTIT.java index ee810257c..5137bd506 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleBufferBasedMQTTIT.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleBufferBasedMQTTIT.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleOverlapTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleOverlapTest.java index 92a2cf800..5a5a34fda 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleOverlapTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/SimpleOverlapTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/WildcardTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/WildcardTest.java index 9a3536b39..a10f3e9e1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/WildcardTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt/WildcardTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ClientCallbackHandler.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ClientCallbackHandler.java index e140442c2..4e38f54d3 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ClientCallbackHandler.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ClientCallbackHandler.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ComplianceTests.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ComplianceTests.java index 27e494a23..9af4521c5 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ComplianceTests.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/ComplianceTests.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTBaseTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTBaseTest.java index dd7030e92..48b918cf2 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTBaseTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTBaseTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTConnectionTest.java index 346b8ca2a..4b3dfff73 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTPublishEventTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTPublishEventTest.java index 8938a2885..e2f84ef13 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTPublishEventTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTPublishEventTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTStoredMessageTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTStoredMessageTest.java index 40867d103..b0aaa7ece 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTStoredMessageTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTStoredMessageTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTSubscriptionImplTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTSubscriptionImplTest.java index 952bad420..83a9aaf5b 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTSubscriptionImplTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MQTTSubscriptionImplTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttAuthSaslTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttAuthSaslTest.java index 1c762959f..8068509c1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttAuthSaslTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttAuthSaslTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttLoggingConfig.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttLoggingConfig.java index 4394048b3..5728bdf34 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttLoggingConfig.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/MqttLoggingConfig.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/QueueSubscriptionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/QueueSubscriptionTest.java index 6159f16be..74f82ba74 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/QueueSubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/QueueSubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleBufferBasedMQTT5IT.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleBufferBasedMQTT5IT.java index e414e29c6..dc12d6bf1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleBufferBasedMQTT5IT.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleBufferBasedMQTT5IT.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleOverlapTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleOverlapTest.java index b76ab2141..1fc1c4325 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleOverlapTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SimpleOverlapTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SubscriptionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SubscriptionTest.java index 23a22633d..5e34842bc 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SystemTopicTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SystemTopicTest.java index d567fd4e5..cbcacc5a7 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SystemTopicTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/SystemTopicTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/packet/properties/MessagePropertiesTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/packet/properties/MessagePropertiesTest.java index 08628ef16..5d3b5b3ce 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/packet/properties/MessagePropertiesTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt5/packet/properties/MessagePropertiesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/BaseMqttSnConfig.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/BaseMqttSnConfig.java index 7e03e889b..f2c0d2a0d 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/BaseMqttSnConfig.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/BaseMqttSnConfig.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -35,8 +35,8 @@ public class BaseMqttSnConfig extends BaseTestConfig { public static Stream createQoSVersionStream() { List args = new ArrayList<>(); for (int qos : QOS_LIST) { - for (int verion : VERSIONS) { - args.add(arguments(qos, verion)); + for (int version : VERSIONS) { + args.add(arguments(qos, version)); } } return args.stream(); @@ -44,8 +44,8 @@ public static Stream createQoSVersionStream() { public static Stream createVersionStream() { List args = new ArrayList<>(); - for (int verion : VERSIONS) { - args.add(arguments(verion)); + for (int version : VERSIONS) { + args.add(arguments(version)); } return args.stream(); } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/Configuration.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/Configuration.java index ebcbec9bd..ebb131f68 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/Configuration.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/Configuration.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MemoryStorage.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MemoryStorage.java index ac8309b2a..ff624b94b 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MemoryStorage.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MemoryStorage.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNConnectionTest.java index 60f2e9001..df5684f73 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNPublishingTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNPublishingTest.java index a2b94df95..42544e85d 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNPublishingTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNPublishingTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNSubscriptionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNSubscriptionTest.java index f3e9b54a3..63b079737 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNSubscriptionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSNSubscriptionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -235,9 +235,7 @@ public void subscribeQoSnTopicAndPublish(int qos, int version) throws Interrupte } Assertions.assertTrue(received.await(TIMEOUT, TimeUnit.MILLISECONDS)); - //client.unsubscribe("predefined/topic"); - - //client.disconnect(); + client.disconnect(); } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnAuthTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnAuthTest.java index 67cd10996..6bcaca2f0 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnAuthTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnAuthTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnClient.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnClient.java index 0d991e046..1457fa552 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnClient.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnClient.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -64,8 +64,7 @@ protected MqttsnClientRuntimeRegistry createClientRuntimeRegistry(IMqttsnCodec c withPort(0); MqttsnOptions options = new MqttsnClientOptions(). - withNetworkAddressEntry("localhost", - NetworkAddress.localhost(port)). + withNetworkAddressEntry("localhost", NetworkAddress.localhost(port)). withContextId(""+ThreadLocalRandom.current().nextLong()). withMaxMessagesInflight(1). withMaxWait(60000). @@ -135,7 +134,7 @@ public void wake(int waitTime) throws MqttsnException { public void disconnect() throws MqttsnException { - client.disconnect(); + client.close(); } public void setWillData(WillDataImpl details) throws MqttsnException { diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnLargeMessageTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnLargeMessageTest.java index 69155ae06..3e7c86cf5 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnLargeMessageTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnLargeMessageTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnSleepTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnSleepTest.java index 01e44efa7..4b6c2557d 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnSleepTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/MqttSnSleepTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/ConnectPacketTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/ConnectPacketTest.java index 2e063a565..4af8f636a 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/ConnectPacketTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/ConnectPacketTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSConnectPacketTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSConnectPacketTest.java index 97249d459..67485c387 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSConnectPacketTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSConnectPacketTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSPacketTransport.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSPacketTransport.java index af3f3bfa3..df1dd9309 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSPacketTransport.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/DTLSPacketTransport.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/HmacConnectPacketTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/HmacConnectPacketTest.java index 322f5d79a..4200e4821 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/HmacConnectPacketTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/HmacConnectPacketTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/PacketTransport.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/PacketTransport.java index 65f91fefd..33ea3903f 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/PacketTransport.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/PacketTransport.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPConnectPacketTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPConnectPacketTest.java index b6173337c..622e1c6b7 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPConnectPacketTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPConnectPacketTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPPacketTransport.java b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPPacketTransport.java index 920a0f188..8b356fba3 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPPacketTransport.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/mqtt_sn/packet/UDPPacketTransport.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/n2k/N2kViaCanBusTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/n2k/N2kViaCanBusTest.java new file mode 100644 index 000000000..3a1a6fc84 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/n2k/N2kViaCanBusTest.java @@ -0,0 +1,521 @@ +package io.mapsmessaging.network.protocol.impl.n2k; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscribedEventManager; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.canbus.device.SocketCanDevice; +import io.mapsmessaging.canbus.device.frames.CanFrame; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class N2kViaCanBusTest extends BaseTestConfig { + + private static final Pattern CANDUMP_PATTERN = + Pattern.compile("^\\(\\d+\\.\\d+\\)\\s+(\\S+)\\s+([0-9A-Fa-f]+)#([0-9A-Fa-f]*)\\s*$"); + + private static final String[] CANDUMP_LINES = new String[]{ + "(1745600961.335462) can0 11FC1063#003DFFFF02000100", + "(1745600961.335462) can0 11FC1063#0101000C0153514C", + "(1745600961.336482) can0 11FC1063#0220536572766572", + "(1745600961.336482) can0 11FC1063#0310FBCF24EB4EF3", + "(1745600961.336482) can0 11FC1063#0402004500000000", + "(1745600961.336482) can0 11FC1063#0504000002000501", + "(1745600961.336482) can0 11FC1063#0644423210FBCF24", + "(1745600961.336482) can0 11FC1063#07EB4EFF20006000", + "(1745600961.336482) can0 11FC1063#080000F33F0000FF", + "(1745600961.336482) can0 11FC1163#002BFFFF02001600", + "(1745600961.336482) can0 11FC1163#01FFFF01000D0148", + "(1745600961.336482) can0 11FC1163#02657265546F5468", + "(1745600961.336482) can0 11FC1163#0365726527020010", + "(1745600961.336482) can0 11FC1163#0401416E64204261", + "(1745600961.336482) can0 11FC1163#05636B2041676169", + "(1745600961.337436) can0 11FC1163#066E1BFFFFFFFFFF", + "(1745600961.337436) can0 11FC1263#001DFFFF02000B01", + "(1745600961.337436) can0 11FC1263#0142656E746C6569", + "(1745600961.337436) can0 11FC1263#02676810FBCF24EB", + "(1745600961.337436) can0 11FC1263#034E02180003F01E", + "(1745600961.337436) can0 11FC1263#0400FFFFFFFFFFFF", + "(1745600961.337436) can0 11FC1363#0039FFFF02006400", + "(1745600961.337436) can0 11FC1363#01FFFF020001000B", + "(1745600961.337436) can0 11FC1363#020142656E746C65", + "(1745600961.337436) can0 11FC1363#0369676800A01CE9", + "(1745600961.337436) can0 11FC1363#0440F32056210010", + "(1745600961.337436) can0 11FC1363#0501456173742042", + "(1745600961.337436) can0 11FC1363#06656E746C656967", + "(1745600961.337436) can0 11FC1363#076800F141EB8047", + "(1745600961.337436) can0 11FC1363#08AA56FFFFFFFFFF", + "(1745600961.337436) can0 11FC1463#003BFFFF03006400", + "(1745600961.337436) can0 11FC1463#01FFFF020001000B", + "(1745600961.337436) can0 11FC1463#020142656E746C65", + "(1745600961.337436) can0 11FC1463#0369676821001001", + "(1745600961.337436) can0 11FC1463#0445617374204265", + "(1745600961.337436) can0 11FC1463#056E746C65696768", + "(1745600961.337436) can0 11FC1463#0663001001466177", + "(1745600961.337436) can0 11FC1463#076B6E6572204265", + "(1745600961.337436) can0 11FC1463#0861636F6EFFFFFF", + "(1745600961.337436) can0 11FC1563#0014FFFF02006400", + "(1745600961.337436) can0 11FC1563#01FFFF020001001E", + "(1745600961.337436) can0 11FC1563#0200FC02001400FD", + "(1745600961.337436) can0 11FC1663#0035FFFF02006400", + "(1745600961.337436) can0 11FC1663#01FFFF0200010013", + "(1745600961.337436) can0 11FC1663#020142656E746C65", + "(1745600961.337436) can0 11FC1663#036967682D436F6D", + "(1745600961.337436) can0 11FC1663#046D656E74020014", + "(1745600961.337436) can0 11FC1663#0501476C656E6875", + "(1745600961.337436) can0 11FC1663#066E746C792D436F", + "(1745600961.338397) can0 11FC1663#076D6D656E74FFFF", + "(1745600961.338397) can0 11FC1763#003DFFFF02000F00", + "(1745600961.338397) can0 11FC1763#01FFFF0200170148", + "(1745600961.338397) can0 11FC1763#0265726520746F20", + "(1745600961.338397) can0 11FC1763#0354686572652D43", + "(1745600961.338397) can0 11FC1763#046F6D6D656E7405", + "(1745600961.338397) can0 11FC1763#05001A01416E6420", + "(1745600961.338397) can0 11FC1763#064261636B204167", + "(1745600961.338397) can0 11FC1763#0761696E202D2043", + "(1745600961.338397) can0 11FC1763#086F6D6D656E74FF", + "(1745600961.338397) can0 11FC1863#002FFFFF02000300", + "(1745600961.338397) can0 11FC1863#010100160153514C", + "(1745600961.338397) can0 11FC1863#0220536572766572", + "(1745600961.338397) can0 11FC1863#03202D20436F6D6D", + "(1745600961.338397) can0 11FC1863#04656E7402000F01", + "(1745600961.338397) can0 11FC1863#05444232202D2043", + "(1745600961.338397) can0 11FC1863#066F6D6D656E74FF", + "(1745600961.338397) can0 11FC1A63#0030000002000200", + "(1745600961.338397) can0 11FC1A63#010100FFFF6C000A", + "(1745600961.338397) can0 11FC1A63#02014D634B696E6E", + "(1745600961.338397) can0 11FC1A63#036F6E404E08ECC0", + "(1745600961.338397) can0 11FC1A63#049649505C000801", + "(1745600961.338397) can0 11FC1A63#054F726D6F6E64A0", + "(1745600961.338397) can0 11FC1A63#06311FEC607E2650", + "(1745600961.338397) can0 11F10163#0023A012AB0F00F8", + "(1745600961.338397) can0 11F10163#0140C2931EFFEB4E", + "(1745600961.338397) can0 11F10163#0210FBCF243D8533", + "(1745600961.338397) can0 11F10163#03E9285F4056FC5B", + "(1745600961.338397) can0 11F10163#043D33009218D439", + "(1745600961.338397) can0 11F10163#05F8FFFFFFFFFFFF", + "(1745600961.338397) can0 11F90463#0022FFA00F000044", + "(1745600961.338397) can0 11F90463#01109D1A29EB4EDB", + "(1745600961.338397) can0 11F90463#0253385500000000", + "(1745600961.338397) can0 11F90463#03010000000060E3", + "(1745600961.338397) can0 11F90463#041600CA91FE0404", + "(1745600961.338397) can0 11F90563#0040FFFF02000100", + "(1745600961.338397) can0 11F90563#010200E00801526F", + "(1745600961.338397) can0 11F90563#0275746531FF0100", + "(1745600961.338397) can0 11F90563#030D01576179706F", + "(1745600961.338397) can0 11F90563#04696E744F6E6500", + "(1745600961.338397) can0 11F90563#0560E31600CA91FE", + "(1745600961.338397) can0 11F90563#0602000D01576179", + "(1745600961.338397) can0 11F90563#07706F696E745477", + "(1745600961.338397) can0 11F90563#086F0069201700C1", + "(1745600961.338397) can0 11F90563#0954FEFFFFFFFFFF", + "(1745600961.338397) can0 11FB1063#004E70FF17324C27", + "(1745600961.338397) can0 11FB1063#011E6D6439303030", + "(1745600961.338397) can0 11FB1063#0236393930303036", + "(1745600961.338397) can0 11FB1063#03390B0131383030", + "(1745600961.338397) can0 11FB1063#042048656C70C066", + "(1745600961.338397) can0 11FB1063#054AE9802CF35510", + "(1745600961.338397) can0 11FB1063#06FBCF2417324C27", + "(1745600961.338397) can0 11FB1063#071E7FFD39303030", + "(1745600961.338397) can0 11FB1063#0837303930303037", + "(1745600961.338397) can0 11FB1063#093010FBCF24EB4E", + "(1745600961.338397) can0 11FB1063#0AFFFF680601496F", + "(1745600961.338397) can0 11FB1063#0B6E61FFFFFFFFFF" + }; + + @Test + void testSimpleSend() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch firstMessageLatch = new CountDownLatch(1); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + firstMessageLatch.countDown(); + messages.add(messageEvent.getMessage()); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("n2kSimpleTest"+System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = + new SubscriptionContextBuilder("/vcan0/#", ClientAcknowledgement.AUTO); + + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + injectCandumpFramesIntoVcan("vcan0", CANDUMP_LINES); + + boolean received = firstMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No N2K messages were published to /vcan0/# after CAN injection"); + Assertions.assertTrue(receivedCount.get() > 0, "Expected at least one message after injection"); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (CANDUMP_LINES.length != messages.size() && attempts < 20); + session.removeSubscription(context.getKey()); + + // 6 invalid messages + Assertions.assertEquals(CANDUMP_LINES.length-6, messages.size(), "Expected message count to match injected CAN frames"); + + for (Message message : messages) { + validatePayload(message); + } + } finally { + close(session); + } + } + + @Test + void testRandomNonN2kFramesAreHandled() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch lastMessageLatch = new CountDownLatch(3); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + messages.add(messageEvent.getMessage()); + lastMessageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("n2kRandomFramesTest"+System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = new SubscriptionContextBuilder("/vcan0/#", ClientAcknowledgement.AUTO); + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + SubscribedEventManager manager = session.addSubscription(context); + + int sourceAddress = 99; + int destinationAddress = 255; + int priority = 4; + + int unknownPgnA = 0x01FF10; + int unknownPgnB = 0x00EA99; + int unknownPgnC = 0x00ABCD; + + int canIdA = buildJ1939CanId(priority, unknownPgnA, destinationAddress, sourceAddress); + int canIdB = buildJ1939CanId(priority, unknownPgnB, destinationAddress, sourceAddress); + int canIdC = buildJ1939CanId(priority, unknownPgnC, destinationAddress, sourceAddress); + byte[] expectedA = hexToBytes("DEADBEEFDEADBEEF"); + byte[] expectedB = hexToBytes("0001020304050607"); + byte[] expectedC = hexToBytes("FFFFFFFFFFFFFFFF"); + + delay(100); + injectRawCanFramesIntoVcan("vcan0", new RawFrame[]{ + new RawFrame(canIdA, true, "DEADBEEFDEADBEEF"), + new RawFrame(canIdB, true, "0001020304050607"), + new RawFrame(canIdC, true, "FFFFFFFFFFFFFFFF") + }); + boolean received = lastMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No messages were published after injecting random frames"); + Assertions.assertEquals(3, receivedCount.get(), "Expected one published message per injected CAN frame"); + session.removeSubscription(context.getKey()); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (messages.size() != 3 && attempts < 20); + + Assertions.assertEquals(3, messages.size(), "Expected three messages in total"); + + validateUnknownPayload(messages.get(0), expectedA); + validateUnknownPayload(messages.get(1), expectedB); + validateUnknownPayload(messages.get(2), expectedC); + + List after = new CopyOnWriteArrayList<>(); + CountDownLatch validLatch = new CountDownLatch(1); + + MessageListener validListener = messageEvent -> { + after.add(messageEvent.getMessage()); + validLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + close(session); + + Session session2 = createSession("n2kRandomFramesTestPhase2", 60, 60, false, validListener); + try { + SubscriptionContextBuilder builder2 = + new SubscriptionContextBuilder("/vcan0/#", ClientAcknowledgement.AUTO); + + SubscriptionContext context2 = builder2 + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session2.addSubscription(context2); + + injectCandumpFramesIntoVcan("vcan0", new String[]{CANDUMP_LINES[0]}); + + boolean ok = validLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(ok, "Server did not continue to process valid frames after random frame injection"); + Assertions.assertEquals(1, after.size(), "Expected exactly one message after valid injection"); + validatePayload(after.get(0)); + } finally { + close(session2); + } + } finally { + close(session); + } + } + + private void validateUnknownPayload(Message message, byte[] expectedPayloadBytes) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root = JsonParser.parseString(payload); + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + assertFieldPresent(object, "canId"); + assertFieldPresent(object, "dlc"); + assertFieldPresent(object, "extended"); + assertFieldPresent(object, "data"); + + int dlc = object.get("dlc").getAsInt(); + Assertions.assertTrue(dlc >= 0 && dlc <= 8, "dlc must be in [0..8]. Payload: " + payload); + + String data = object.get("data").getAsString(); + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(data); + } catch (IllegalArgumentException e) { + Assertions.fail("data is not valid Base64. Payload: " + payload, e); + return; + } + Assertions.assertEquals(dlc, decoded.length, "Decoded data length must match dlc. Payload: " + payload); + + Assertions.assertFalse(object.has("j1939"), "Unknown payload must not include j1939. Payload: " + payload); + Assertions.assertFalse(object.has("n2k"), "Unknown payload must not include n2k. Payload: " + payload); + Assertions.assertArrayEquals( + expectedPayloadBytes, + decoded, + "Decoded CAN payload does not match injected payload. Payload: " + payload + ); + + } + + private void validatePayload(Message message) { + Assertions.assertNotNull(message, "Message must not be null"); + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root; + try { + root = JsonParser.parseString(payload); + } catch (Exception e) { + Assertions.fail("Payload is not valid JSON. Payload: " + payload, e); + return; + } + + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + assertFieldPresent(object, "canId"); + assertFieldPresent(object, "dlc"); + assertFieldPresent(object, "extended"); + assertFieldPresent(object, "data"); + + long canId = object.get("canId").getAsLong(); + int dlc = object.get("dlc").getAsInt(); + boolean extended = object.get("extended").getAsBoolean(); + String data = object.get("data").getAsString(); + + Assertions.assertTrue(canId >= 0, "canId must be >= 0. Payload: " + payload); + Assertions.assertTrue(dlc >= 0 && dlc <= 8, "dlc must be in [0..8]. dlc=" + dlc + " Payload: " + payload); + Assertions.assertFalse(data.isBlank(), "data must not be blank. Payload: " + payload); + + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(data); + } catch (IllegalArgumentException e) { + Assertions.fail("data is not valid Base64. data=" + data + " Payload: " + payload, e); + return; + } + + Assertions.assertEquals(dlc, decoded.length, "Base64 decoded data length must match dlc. dlc=" + dlc + + " decoded=" + decoded.length + " Payload: " + payload); + + boolean inferredExtended = canId > 0x7FF; + Assertions.assertEquals(inferredExtended, extended, "extended flag mismatch. canId=" + canId + + " inferredExtended=" + inferredExtended + " Payload: " + payload); + + if (object.has("j1939") && !object.get("j1939").isJsonNull()) { + Assertions.assertTrue(object.get("j1939").isJsonObject(), "j1939 must be an object if present. Payload: " + payload); + JsonObject j1939 = object.getAsJsonObject("j1939"); + + assertFieldPresent(j1939, "pgn"); + int pgn = j1939.get("pgn").getAsInt(); + Assertions.assertTrue(pgn >= 0 && pgn <= 262143, "pgn must be in [0..262143]. pgn=" + pgn + " Payload: " + payload); + } + } + + private int buildJ1939CanId(int priority, int pgn, int destination, int source) { + Assertions.assertTrue(priority >= 0 && priority <= 7, "priority must be [0..7]"); + Assertions.assertTrue(pgn >= 0 && pgn <= 262143, "pgn must be [0..262143]"); + Assertions.assertTrue(destination >= 0 && destination <= 255, "destination must be [0..255]"); + Assertions.assertTrue(source >= 0 && source <= 255, "source must be [0..255]"); + + int dp = (pgn >> 16) & 0x01; + int pf = (pgn >> 8) & 0xFF; + int ps; + + if (pf < 240) { + ps = destination & 0xFF; + } else { + ps = pgn & 0xFF; + } + + int canId = 0; + canId |= (priority & 0x7) << 26; + canId |= (dp & 0x1) << 24; + canId |= (pf & 0xFF) << 16; + canId |= (ps & 0xFF) << 8; + canId |= (source & 0xFF); + + return canId; + } + + private void assertFieldPresent(JsonObject object, String fieldName) { + Assertions.assertTrue(object.has(fieldName), "Missing field '" + fieldName + "'. Object: " + object); + Assertions.assertFalse(object.get(fieldName).isJsonNull(), "Field '" + fieldName + "' must not be null. Object: " + object); + } + + + private void injectRawCanFramesIntoVcan(String interfaceName, RawFrame[] frames) throws IOException { + try (SocketCanDevice socketCanDevice = new SocketCanDevice(interfaceName)) { + for (RawFrame rawFrame : frames) { + byte[] payload = hexToBytes(rawFrame.dataHex); + Assertions.assertTrue(payload.length <= 8, "Raw frame payload must be <= 8 bytes"); + + CanFrame frame = new CanFrame(rawFrame.canId, rawFrame.extended, payload.length, payload); + socketCanDevice.writeFrame(frame); + delay(50); + } + } + catch(Throwable t) { + t.printStackTrace(); + } + } + + + private void injectCandumpFramesIntoVcan(String interfaceName, String[] candumpLines) throws IOException { + try (SocketCanDevice socketCanDevice = new SocketCanDevice(interfaceName)) { + for (String line : candumpLines) { + ParsedFrame parsedFrame = parseCandumpLine(line); + if (parsedFrame == null) { + continue; + } + + boolean extendedFrame = parsedFrame.canId > 0x7FF; + CanFrame frame = new CanFrame(parsedFrame.canId, extendedFrame, parsedFrame.dataLength, parsedFrame.data); + socketCanDevice.writeFrame(frame); + delay(10); + } + } + } + + private ParsedFrame parseCandumpLine(String line) { + Matcher matcher = CANDUMP_PATTERN.matcher(line); + if (!matcher.matches()) { + return null; + } + + String canIdHex = matcher.group(2); + String dataHex = matcher.group(3); + + if (dataHex.length() % 2 != 0) { + throw new IllegalArgumentException("Odd-length CAN data field: " + line); + } + + int dataLength = dataHex.length() / 2; + if (dataLength > 8) { + throw new IllegalArgumentException("CAN frame payload exceeds 8 bytes (not a raw frame): " + line); + } + + int canId = (int) Long.parseLong(canIdHex, 16); + byte[] data = hexToBytes(dataHex); + + return new ParsedFrame(canId, data, dataLength); + } + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + byte[] bytes = new byte[length / 2]; + + for (int i = 0; i < length; i += 2) { + int value = Integer.parseInt(hex.substring(i, i + 2), 16); + bytes[i / 2] = (byte) value; + } + + return bytes; + } + + private static final class ParsedFrame { + private final int canId; + private final byte[] data; + private final int dataLength; + + private ParsedFrame(int canId, byte[] data, int dataLength) { + this.canId = canId; + this.data = data; + this.dataLength = dataLength; + } + } + private static final class RawFrame { + private final int canId; + private final boolean extended; + private final String dataHex; + + private RawFrame(int canId, boolean extended, String dataHex) { + this.canId = canId; + this.extended = extended; + this.dataHex = dataHex; + } + } + +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsControlFramesTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsControlFramesTest.java index 4c1446108..37f249a2e 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsControlFramesTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsControlFramesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -47,10 +47,12 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } + @Test @Order(1) void testPingPong() throws Exception { @@ -87,14 +89,20 @@ void testInfoParsing() { void testConnectFlags() throws Exception { Options options = new Options.Builder() .server("nats://localhost:4222") - .noEcho() // disable echo - .verbose() // request +OK - .pedantic() // enable strict checking + .noEcho() + .verbose() + .pedantic() .connectionTimeout(Duration.ofSeconds(5)) .build(); - try (Connection customConn = Nats.connect(options)) { + Connection customConn = Nats.connect(options); + try { assertEquals(Connection.Status.CONNECTED, customConn.getStatus()); } + finally { + customConn.drain(Duration.ofSeconds(2)); + customConn.close(); + } } + } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsEdgeCasesTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsEdgeCasesTest.java index 9a873c923..d15ac7839 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsEdgeCasesTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsEdgeCasesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -26,7 +26,6 @@ import io.nats.client.Options; import org.junit.jupiter.api.*; -import java.io.IOException; import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -51,6 +50,7 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } @@ -58,15 +58,13 @@ void teardown() throws Exception { @Test @Order(1) void testInvalidSubject() { - assertThrows(IllegalArgumentException.class, () -> { - connection.publish("invalid subject", "bad".getBytes()); - }); + assertThrows(IllegalArgumentException.class, () -> connection.publish("invalid subject", "bad".getBytes())); } @Test @Order(2) void testOversizedPayload() { - byte[] largePayload = new byte[1024 * 1024 * 10]; // 10MB (likely exceeds server config) + byte[] largePayload = new byte[1024 * 1024 * 10]; assertThrows(IllegalArgumentException.class, () -> { connection.publish("test.large", largePayload); connection.flush(Duration.ofSeconds(5)); @@ -76,9 +74,7 @@ void testOversizedPayload() { @Test @Order(3) void testMalformedMessage() { - // Simulate broken stream manually assertDoesNotThrow(() -> { - // This won't throw directly here, but your server should reject it internally connection.publish("test.malformed", "MSG without length".getBytes()); connection.flush(Duration.ofSeconds(1)); }); @@ -90,18 +86,29 @@ void testDuplicateSubscriptionIds() throws Exception { String subject = "edge.dup.sid"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher d1 = connection.createDispatcher(msg -> future.complete("first")); - d1.subscribe(subject); + Dispatcher dispatcherOne = null; + Dispatcher dispatcherTwo = null; + try { + dispatcherOne = connection.createDispatcher(msg -> future.complete("first")); + dispatcherOne.subscribe(subject); - // Duplicate subscription to same subject using different Dispatcher - Dispatcher d2 = connection.createDispatcher(msg -> future.complete("second")); - d2.subscribe(subject); + dispatcherTwo = connection.createDispatcher(msg -> future.complete("second")); + dispatcherTwo.subscribe(subject); - connection.publish(subject, "check".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, "check".getBytes()); + connection.flush(Duration.ofSeconds(2)); - String who = future.get(2, TimeUnit.SECONDS); - assertTrue(who.equals("first") || who.equals("second")); + String who = future.get(2, TimeUnit.SECONDS); + assertTrue(who.equals("first") || who.equals("second")); + } + finally { + if (dispatcherOne != null && connection != null) { + connection.closeDispatcher(dispatcherOne); + } + if (dispatcherTwo != null && connection != null) { + connection.closeDispatcher(dispatcherTwo); + } + } } @Test @@ -111,18 +118,28 @@ void testMultipleMessagesInSinglePacket() throws Exception { CompletableFuture counter = new CompletableFuture<>(); int[] count = {0}; - Dispatcher dispatcher = connection.createDispatcher(msg -> { - count[0]++; - if (count[0] == 3) counter.complete(count[0]); - }); - dispatcher.subscribe(subject); - - for (int i = 0; i < 3; i++) { - connection.publish(subject, ("msg" + i).getBytes()); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> { + count[0]++; + if (count[0] == 3) { + counter.complete(count[0]); + } + }); + dispatcher.subscribe(subject); + + for (int i = 0; i < 3; i++) { + connection.publish(subject, ("msg" + i).getBytes()); + } + connection.flush(Duration.ofSeconds(20)); + + Integer received = counter.get(3, TimeUnit.SECONDS); + assertEquals(3, received); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } } - connection.flush(Duration.ofSeconds(20)); - - Integer received = counter.get(3, TimeUnit.SECONDS); - assertEquals(3, received); } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsHeadersTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsHeadersTest.java index 168431639..79dfd09a5 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsHeadersTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsHeadersTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } @@ -60,18 +61,27 @@ void testHeaderDelivery() throws Exception { String val = "hval"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(future::complete); - dispatcher.subscribe(subject); - Headers headers = new Headers(); - headers.add(key, val); - connection.publish(subject, null, headers, "HeaderTest".getBytes()); - connection.flush(Duration.ofSeconds(2)); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(future::complete); + dispatcher.subscribe(subject); - Message msg = future.get(3, TimeUnit.SECONDS); - assertEquals("HeaderTest", new String(msg.getData())); - assertTrue(msg.hasHeaders()); - assertEquals(val, msg.getHeaders().getFirst(key)); + Headers headers = new Headers(); + headers.add(key, val); + connection.publish(subject, null, headers, "HeaderTest".getBytes()); + connection.flush(Duration.ofSeconds(2)); + + Message msg = future.get(3, TimeUnit.SECONDS); + assertEquals("HeaderTest", new String(msg.getData())); + assertTrue(msg.hasHeaders()); + assertEquals(val, msg.getHeaders().getFirst(key)); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -84,16 +94,25 @@ void testMultipleHeaders() throws Exception { headers.add("beta", "2"); CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(future::complete); - dispatcher.subscribe(subject); - connection.publish(subject, null, headers, "MultiHeader".getBytes()); - connection.flush(Duration.ofSeconds(2)); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(future::complete); + dispatcher.subscribe(subject); + + connection.publish(subject, null, headers, "MultiHeader".getBytes()); + connection.flush(Duration.ofSeconds(2)); - Message msg = future.get(3, TimeUnit.SECONDS); - assertTrue(msg.hasHeaders()); - assertEquals("1", msg.getHeaders().getFirst("alpha")); - assertEquals("2", msg.getHeaders().getFirst("beta")); + Message msg = future.get(3, TimeUnit.SECONDS); + assertTrue(msg.hasHeaders()); + assertEquals("1", msg.getHeaders().getFirst("alpha")); + assertEquals("2", msg.getHeaders().getFirst("beta")); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -104,16 +123,25 @@ void testHeaderOnlyMessage() throws Exception { headers.add("flag", "true"); CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(future::complete); - dispatcher.subscribe(subject); - connection.publish(subject, null, headers, new byte[0]); - connection.flush(Duration.ofSeconds(2)); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(future::complete); + dispatcher.subscribe(subject); + + connection.publish(subject, null, headers, new byte[0]); + connection.flush(Duration.ofSeconds(2)); - Message msg = future.get(3, TimeUnit.SECONDS); - assertTrue(msg.hasHeaders()); - assertEquals("true", msg.getHeaders().getFirst("flag")); - assertEquals(0, msg.getData().length); + Message msg = future.get(3, TimeUnit.SECONDS); + assertTrue(msg.hasHeaders()); + assertEquals("true", msg.getHeaders().getFirst("flag")); + assertEquals(0, msg.getData().length); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -127,14 +155,23 @@ void testHeaderSpecialChars() throws Exception { headers.add(key, val); CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(future::complete); - dispatcher.subscribe(subject); - connection.publish(subject, null, headers, "Encoding Test".getBytes()); - connection.flush(Duration.ofSeconds(2)); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(future::complete); + dispatcher.subscribe(subject); - Message msg = future.get(3, TimeUnit.SECONDS); - assertEquals("Encoding Test", new String(msg.getData())); - assertEquals(val, msg.getHeaders().getFirst(key)); + connection.publish(subject, null, headers, "Encoding Test".getBytes()); + connection.flush(Duration.ofSeconds(2)); + + Message msg = future.get(3, TimeUnit.SECONDS); + assertEquals("Encoding Test", new String(msg.getData())); + assertEquals(val, msg.getHeaders().getFirst(key)); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsJetStreamLikeTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsJetStreamLikeTest.java index 141e9a07e..3830a77a6 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsJetStreamLikeTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsJetStreamLikeTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -48,6 +48,7 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } @@ -58,22 +59,28 @@ void testDurableSubWithManualAckSimulation() throws Exception { String subject = "js.manual.ack"; CompletableFuture acked = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - // Simulate manual ack by replying to reply-to (e.g., _INBOX.abc) - if (msg.getReplyTo() != null) { - connection.publish(msg.getReplyTo(), "+ACK".getBytes()); - acked.complete("acked"); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> { + if (msg.getReplyTo() != null) { + connection.publish(msg.getReplyTo(), "+ACK".getBytes()); + acked.complete("acked"); + } + }); + dispatcher.subscribe(subject); + + String inbox = "_INBOX." + UUID.randomUUID(); + connection.publish(subject, inbox, "Need ACK".getBytes()); + connection.flush(Duration.ofSeconds(2)); + + String result = acked.get(3, TimeUnit.SECONDS); + assertEquals("acked", result); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); } - }); - dispatcher.subscribe(subject); - - // Publish with a reply-to inbox (manual ack trigger) - String inbox = "_INBOX." + UUID.randomUUID(); - connection.publish(subject, inbox, "Need ACK".getBytes()); - connection.flush(Duration.ofSeconds(2)); - - String result = acked.get(3, TimeUnit.SECONDS); - assertEquals("acked", result); + } } @Test @@ -83,26 +90,39 @@ void testReplaySimulation() throws Exception { String message = "Replay test"; CompletableFuture firstReceive = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> firstReceive.complete(new String(msg.getData()))); - dispatcher.subscribe(subject); + CompletableFuture secondReceive = new CompletableFuture<>(); - connection.publish(subject, message.getBytes()); - connection.flush(Duration.ofSeconds(1)); + Dispatcher dispatcher = null; + Dispatcher newDispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> firstReceive.complete(new String(msg.getData()))); + dispatcher.subscribe(subject); - String received = firstReceive.get(2, TimeUnit.SECONDS); - assertEquals(message, received); + connection.publish(subject, message.getBytes()); + connection.flush(Duration.ofSeconds(1)); - // Simulate replay (manual resend) - CompletableFuture secondReceive = new CompletableFuture<>(); - dispatcher.unsubscribe(subject); - Dispatcher newDispatcher = connection.createDispatcher(msg -> secondReceive.complete(new String(msg.getData()))); - newDispatcher.subscribe(subject); + String received = firstReceive.get(2, TimeUnit.SECONDS); + assertEquals(message, received); + + dispatcher.unsubscribe(subject); + + newDispatcher = connection.createDispatcher(msg -> secondReceive.complete(new String(msg.getData()))); + newDispatcher.subscribe(subject); - connection.publish(subject, message.getBytes()); // Replay event - connection.flush(Duration.ofSeconds(1)); + connection.publish(subject, message.getBytes()); + connection.flush(Duration.ofSeconds(1)); - String replayed = secondReceive.get(2, TimeUnit.SECONDS); - assertEquals(message, replayed); + String replayed = secondReceive.get(2, TimeUnit.SECONDS); + assertEquals(message, replayed); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + if (newDispatcher != null && connection != null) { + connection.closeDispatcher(newDispatcher); + } + } } @Test @@ -113,20 +133,24 @@ void testMsgWithInboxReplyRouting() throws Exception { CompletableFuture inboxResponder = new CompletableFuture<>(); - // Listen on the replyTo - Dispatcher responder = connection.createDispatcher(msg -> { - inboxResponder.complete(new String(msg.getData())); - }); - responder.subscribe(replyTo); + Dispatcher responder = null; + try { + responder = connection.createDispatcher(msg -> inboxResponder.complete(new String(msg.getData()))); + responder.subscribe(replyTo); - connection.publish(subject, replyTo, "Send me a reply".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, replyTo, "Send me a reply".getBytes()); + connection.flush(Duration.ofSeconds(2)); - // Simulate reply - connection.publish(replyTo, "Replying back".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(replyTo, "Replying back".getBytes()); + connection.flush(Duration.ofSeconds(2)); - String reply = inboxResponder.get(3, TimeUnit.SECONDS); - assertEquals("Replying back", reply); + String reply = inboxResponder.get(3, TimeUnit.SECONDS); + assertEquals("Replying back", reply); + } + finally { + if (responder != null && connection != null) { + connection.closeDispatcher(responder); + } + } } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsPubSubTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsPubSubTest.java index 4f43ea819..b2cb8011a 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsPubSubTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsPubSubTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -47,6 +47,7 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } @@ -57,16 +58,22 @@ void testBasicPubSub() throws Exception { String subject = "pubsub.basic"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - future.complete(new String(msg.getData())); - }); - dispatcher.subscribe(subject); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> future.complete(new String(msg.getData()))); + dispatcher.subscribe(subject); - connection.publish(subject, "Basic Test".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, "Basic Test".getBytes()); + connection.flush(Duration.ofSeconds(2)); - String received = future.get(3, TimeUnit.SECONDS); - assertEquals("Basic Test", received); + String received = future.get(3, TimeUnit.SECONDS); + assertEquals("Basic Test", received); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -75,16 +82,22 @@ void testWildcardPubSub() throws Exception { String subject = "pubsub.test.foo"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - future.complete(msg.getSubject()); - }); - dispatcher.subscribe("pubsub.test.*"); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> future.complete(msg.getSubject())); + dispatcher.subscribe("pubsub.test.*"); - connection.publish(subject, "match".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, "match".getBytes()); + connection.flush(Duration.ofSeconds(2)); - String receivedSubject = future.get(3, TimeUnit.SECONDS); - assertEquals(subject, receivedSubject); + String receivedSubject = future.get(3, TimeUnit.SECONDS); + assertEquals(subject, receivedSubject); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -94,17 +107,29 @@ void testQueueGroupDelivery() throws Exception { CompletableFuture future1 = new CompletableFuture<>(); CompletableFuture future2 = new CompletableFuture<>(); - Dispatcher d1 = connection.createDispatcher(msg -> future1.complete("worker1")); - Dispatcher d2 = connection.createDispatcher(msg -> future2.complete("worker2")); + Dispatcher dispatcherOne = null; + Dispatcher dispatcherTwo = null; + try { + dispatcherOne = connection.createDispatcher(msg -> future1.complete("worker1")); + dispatcherTwo = connection.createDispatcher(msg -> future2.complete("worker2")); - d1.subscribe(subject, "group1"); - d2.subscribe(subject, "group1"); + dispatcherOne.subscribe(subject, "group1"); + dispatcherTwo.subscribe(subject, "group1"); - connection.publish(subject, "Queued".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, "Queued".getBytes()); + connection.flush(Duration.ofSeconds(2)); - String winner = CompletableFuture.anyOf(future1, future2).get(3, TimeUnit.SECONDS).toString(); - assertTrue(winner.equals("worker1") || winner.equals("worker2")); + String winner = CompletableFuture.anyOf(future1, future2).get(3, TimeUnit.SECONDS).toString(); + assertTrue(winner.equals("worker1") || winner.equals("worker2")); + } + finally { + if (dispatcherOne != null && connection != null) { + connection.closeDispatcher(dispatcherOne); + } + if (dispatcherTwo != null && connection != null) { + connection.closeDispatcher(dispatcherTwo); + } + } } @Test @@ -113,13 +138,21 @@ void testUnsubDoesNotReceive() throws Exception { String subject = "pubsub.unsub"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> future.complete("FAIL")); - dispatcher.subscribe(subject); - dispatcher.unsubscribe(subject); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> future.complete("FAIL")); + dispatcher.subscribe(subject); + dispatcher.unsubscribe(subject); - connection.publish(subject, "No listener".getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, "No listener".getBytes()); + connection.flush(Duration.ofSeconds(2)); - assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS)); + assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS)); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsVerbTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsVerbTest.java index 279a8a9b1..2674721d5 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsVerbTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/NatsVerbTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class NatsVerbsTest extends BaseTestConfig { +class NatsVerbTest extends BaseTestConfig { private Connection connection; @@ -51,6 +51,7 @@ void setup() throws Exception { @AfterEach void teardown() throws Exception { if (connection != null) { + connection.drain(Duration.ofSeconds(2)); connection.close(); } } @@ -68,16 +69,22 @@ void testPubSub() throws Exception { String message = "Hello NATS"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - future.complete(new String(msg.getData())); - }); - dispatcher.subscribe(subject); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> future.complete(new String(msg.getData()))); + dispatcher.subscribe(subject); - connection.publish(subject, message.getBytes()); - connection.flush(Duration.ofSeconds(2)); + connection.publish(subject, message.getBytes()); + connection.flush(Duration.ofSeconds(2)); - String received = future.get(6, TimeUnit.SECONDS); - assertEquals(message, received, "Received message should match published message"); + String received = future.get(6, TimeUnit.SECONDS); + assertEquals(message, received, "Received message should match published message"); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @@ -86,22 +93,27 @@ void testUnsub() throws Exception { String subject = "test.unsub"; CompletableFuture future = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - future.complete(new String(msg.getData())); - }); - dispatcher.subscribe(subject); - dispatcher.unsubscribe(subject); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> future.complete(new String(msg.getData()))); + dispatcher.subscribe(subject); + dispatcher.unsubscribe(subject); - connection.publish(subject, "Should not be received".getBytes()); - connection.flush(Duration.ofSeconds(5)); + connection.publish(subject, "Should not be received".getBytes()); + connection.flush(Duration.ofSeconds(5)); - assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS)); + assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS)); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } @Test @Order(4) void testPingPong() throws Exception { - // flush() sends PING and waits for PONG connection.flush(Duration.ofSeconds(2)); assertTrue(connection.getStatus() == Connection.Status.CONNECTED, "Still connected after flush (PING/PONG)"); } @@ -109,9 +121,7 @@ void testPingPong() throws Exception { @Test @Order(5) void testErrorHandling() throws Exception { - assertThrows(IllegalArgumentException.class, () -> { - connection.publish("", "bad".getBytes()); - }); + assertThrows(IllegalArgumentException.class, () -> connection.publish("", "bad".getBytes())); } @Test @@ -125,30 +135,30 @@ void testPubSubWithHeaders() throws Exception { CompletableFuture payloadFuture = new CompletableFuture<>(); CompletableFuture headerFuture = new CompletableFuture<>(); - Dispatcher dispatcher = connection.createDispatcher(msg -> { - if (msg.hasHeaders()) { - String val = msg.getHeaders().getFirst(headerKey); - headerFuture.complete(val); - } else { - headerFuture.complete(null); - } - payloadFuture.complete(new String(msg.getData())); - }); - dispatcher.subscribe(subject); + Dispatcher dispatcher = null; + try { + dispatcher = connection.createDispatcher(msg -> { + headerFuture.complete(msg.hasHeaders() ? msg.getHeaders().getFirst(headerKey) : null); + payloadFuture.complete(new String(msg.getData())); + }); + dispatcher.subscribe(subject); - // Manually create headers - Headers headers = new Headers(); - headers.add(headerKey, headerValue); + Headers headers = new Headers(); + headers.add(headerKey, headerValue); - // Publish with headers - connection.publish(subject, null, headers, messageBody.getBytes()); - connection.flush(Duration.ofSeconds(10)); + connection.publish(subject, null, headers, messageBody.getBytes()); + connection.flush(Duration.ofSeconds(10)); - // Validate - String receivedPayload = payloadFuture.get(6, TimeUnit.SECONDS); - String receivedHeader = headerFuture.get(6, TimeUnit.SECONDS); + String receivedPayload = payloadFuture.get(6, TimeUnit.SECONDS); + String receivedHeader = headerFuture.get(6, TimeUnit.SECONDS); - assertEquals(messageBody, receivedPayload, "Hello with Headers"); - assertEquals(headerValue, receivedHeader, "Header value should match"); + assertEquals(messageBody, receivedPayload, "Hello with Headers"); + assertEquals(headerValue, receivedHeader, "Header value should match"); + } + finally { + if (dispatcher != null && connection != null) { + connection.closeDispatcher(dispatcher); + } + } } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/HighFanoutOrderingTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/HighFanoutOrderingTest.java index 2e5931afa..c42211b55 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/HighFanoutOrderingTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/HighFanoutOrderingTest.java @@ -1,22 +1,3 @@ -/* - * - * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. - * - * Licensed under the Apache License, Version 2.0 with the Commons Clause - * (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 - * https://commonsclause.com/ - * - * 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 io.mapsmessaging.network.protocol.impl.nats.conv; import io.mapsmessaging.test.BaseTestConfig; @@ -27,7 +8,10 @@ import org.junit.jupiter.api.Test; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -36,43 +20,68 @@ class HighFanoutOrderingTest extends BaseTestConfig { @Test void testHighFanoutOrdering() throws Exception { - final int nConns = 100; + final int nConns = 10; final int nSubs = 100; final int nPubs = 500; - final AtomicInteger[] counters = new AtomicInteger[nConns]; - String url = "nats://localhost:4222"; // Assume NATS server is running locally + final AtomicInteger[] counters = new AtomicInteger[nConns]; + String url = "nats://localhost:4222"; String subject = "test.inbox." + System.nanoTime(); CountDownLatch latch = new CountDownLatch(nConns * nSubs); - for (int i = 0; i < nConns; i++) { - Connection conn = Nats.connect(new Options.Builder().server(url).build()); - AtomicInteger local = new AtomicInteger(); - counters[i] = local; - for (int j = 0; j < nSubs; j++) { - AtomicInteger expected = new AtomicInteger(0); - Dispatcher d = conn.createDispatcher(msg -> { - local.incrementAndGet(); - int val = Integer.parseInt(new String(msg.getData())); - int exp = expected.getAndIncrement(); - if (val == exp && exp + 1 == nPubs) { - latch.countDown(); - } - }); - d.subscribe(subject); + List connections = new ArrayList<>(nConns); + List dispatchers = new ArrayList<>(nConns); + + Connection publisherConnection = null; + + try { + for (int i = 0; i < nConns; i++) { + Connection conn = Nats.connect(new Options.Builder().server(url).build()); + connections.add(conn); + + AtomicInteger local = new AtomicInteger(); + counters[i] = local; + + Dispatcher dispatcher = conn.createDispatcher(msg -> local.incrementAndGet()); + dispatchers.add(dispatcher); + + for (int j = 0; j < nSubs; j++) { + AtomicInteger expected = new AtomicInteger(0); + + dispatcher.subscribe(subject, msg -> { + int val = Integer.parseInt(new String(msg.getData())); + int exp = expected.getAndIncrement(); + if (val == exp && exp + 1 == nPubs) { + latch.countDown(); + } + }); + } } - } - // Publisher connection - try (Connection pubConn = Nats.connect(new Options.Builder().server(url).build())) { + publisherConnection = Nats.connect(new Options.Builder().server(url).build()); for (int i = 0; i < nPubs; i++) { - pubConn.publish(subject, Integer.toString(i).getBytes()); - pubConn.flush(Duration.ofSeconds(10)); + publisherConnection.publish(subject, Integer.toString(i).getBytes()); + } + publisherConnection.flush(Duration.ofSeconds(30)); + + boolean completed = latch.await(30, TimeUnit.SECONDS); + assertEquals(true, completed, "Not all subscriptions received ordered messages"); + } + finally { + if (publisherConnection != null) { + publisherConnection.close(); + } + + for (int i = 0; i < connections.size(); i++) { + Connection conn = connections.get(i); + Dispatcher dispatcher = dispatchers.get(i); + + if (dispatcher != null) { + conn.closeDispatcher(dispatcher); + } + conn.close(); } } - // Wait until all subs receive messages in order - boolean completed = latch.await(30, java.util.concurrent.TimeUnit.SECONDS); - assertEquals(true, completed, "Not all subscriptions received ordered messages"); } } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsPingBehaviorTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsPingBehaviorTest.java index 79ebc2945..4eca51bd1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsPingBehaviorTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsPingBehaviorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsProtocolBasicsTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsProtocolBasicsTest.java index 9c912d616..20cd22874 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsProtocolBasicsTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsProtocolBasicsTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsTestHelpers.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsTestHelpers.java index 152e23653..0419ec29e 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsTestHelpers.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/conv/NatsTestHelpers.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamBaseTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamBaseTest.java index 1c77b6652..c0594cfa1 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamBaseTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamBaseTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -60,17 +60,26 @@ void setupJetStream() throws Exception { @AfterEach void teardownJetStream() throws Exception { - if (natsConnection != null && natsConnection.getStatus() != Connection.Status.CLOSED) { + if (natsConnection == null) { + return; + } + if (natsConnection.getStatus() == Connection.Status.CLOSED) { + return; + } + try { + natsConnection.drain(Duration.ofSeconds(2)); + } + finally { natsConnection.close(); } } - protected JsonObject requestJetStreamInfo() throws Exception { natsConnection.flush(Duration.ofSeconds(1)); Message msg = natsConnection.request("$JS.API.INFO", null, Duration.ofSeconds(4)); - if (msg == null || msg.getData() == null) return null; + if (msg == null || msg.getData() == null) { + return null; + } return JsonParser.parseString(new String(msg.getData(), StandardCharsets.UTF_8)).getAsJsonObject(); } - } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamConsumerTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamConsumerTest.java index eaa7825fd..e86560922 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamConsumerTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamConsumerTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamInfoTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamInfoTest.java index 720f71243..ba11828c6 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamInfoTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamInfoTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamStreamTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamStreamTest.java index cefb74da3..3e6e2a871 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamStreamTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/nats/jetstream/JetStreamStreamTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -74,13 +74,17 @@ void testJetStreamUpdate() throws Exception { List f = jetStreamManagement.getStreams(); Assertions.assertNotNull(f); Assertions.assertFalse(f.isEmpty()); + boolean found = false; for(StreamInfo si : f) { - for(String subject: si.getConfig().getSubjects()) { - Assertions.assertNotEquals("topic1", subject); - Assertions.assertNotEquals("topic2", subject); - Assertions.assertTrue(subject.equals("folder1.topic1") || subject.equals("folder.folder1.folder2.topic1")); + if(si.getConfig().getName().equals(streamInfo.getConfig().getName())) { + for(String subject: si.getConfig().getSubjects()) { + Assertions.assertNotEquals("topic1", subject); + Assertions.assertNotEquals("topic2", subject); + found = (subject.equals("folder1.topic1") || subject.equals("folder.folder1.folder2.topic1")); + } } } + Assertions.assertTrue(found); } @@ -116,6 +120,8 @@ void testJetStreamDelete() throws Exception { .allowMessageTtl(true) .discardPolicy(DiscardPolicy.Old) .build(); + List start = jetStreamManagement.getStreams(); + jetStreamManagement.addStream(streamConfiguration); StreamInfo streamInfo = jetStreamManagement.getStreamInfo("nats_test"); Assertions.assertNotNull(streamInfo); @@ -123,6 +129,6 @@ void testJetStreamDelete() throws Exception { Assertions.assertTrue(jetStreamManagement.deleteStream("nats_test")); List f = jetStreamManagement.getStreams(); Assertions.assertNotNull(f); - Assertions.assertTrue(f.isEmpty()); + Assertions.assertEquals(f.size(), start.size()); } } \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolFragmentedDetectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolFragmentedDetectionTest.java new file mode 100644 index 000000000..120c93d26 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolFragmentedDetectionTest.java @@ -0,0 +1,188 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commonsclause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.UnknownHostException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolFragmentedDetectionTest { + + @Test + void v1_required_underflowUntilComplete_enabledGivesUp() throws Exception { + byte[] fullHeader = buildV1Header(); + assertTrue(fullHeader.length > 6); + + // REQUIRED: underflow until we have the complete header (incl CRLF), then parse ok. + for (int i = 1; i < fullHeader.length; i++) { + Packet packet = TestPacketFactory.packetOf(slice(fullHeader, i)); + assertThrows(BufferUnderflowException.class, () -> detect(ProxyProtocolMode.REQUIRED, packet), + "REQUIRED: expected BufferUnderflow at fragment size " + i); + } + + Packet complete = TestPacketFactory.packetOf(fullHeader); + ProxyProtocolInfo info = detect(ProxyProtocolMode.REQUIRED, complete); + assertNotNull(info); + assertEquals("v1", info.getProtocolVersion()); + assertNotNull(info.getSource()); + assertNotNull(info.getDestination()); + + // ENABLED: give up on underflow and return null (do not block protocol). + for (int i = 1; i < fullHeader.length; i++) { + Packet packet = TestPacketFactory.packetOf(slice(fullHeader, i)); + ProxyProtocolInfo enabledInfo = assertDoesNotThrow(() -> detect(ProxyProtocolMode.ENABLED, packet)); + assertNull(enabledInfo, "ENABLED: expected null (give up) at fragment size " + i); + assertEquals(0, packet.position(), "ENABLED: must not consume bytes when giving up"); + } + + Packet enabledComplete = TestPacketFactory.packetOf(fullHeader); + ProxyProtocolInfo enabledParsed = detect(ProxyProtocolMode.ENABLED, enabledComplete); + assertNotNull(enabledParsed); + assertEquals("v1", enabledParsed.getProtocolVersion()); + } + + @Test + void v2_required_underflowUntilComplete_enabledGivesUp() throws Exception { + byte[] fullHeader = buildV2HeaderTcp4(); + assertTrue(fullHeader.length >= 16); + + // REQUIRED: underflow until we have the complete header (sig+ver/cmd+fam+len+addr block), then parse ok. + for (int i = 1; i < fullHeader.length; i++) { + Packet packet = TestPacketFactory.packetOf(slice(fullHeader, i)); + assertThrows(BufferUnderflowException.class, () -> detect(ProxyProtocolMode.REQUIRED, packet), + "REQUIRED: expected BufferUnderflow at fragment size " + i); + } + + Packet complete = TestPacketFactory.packetOf(fullHeader); + ProxyProtocolInfo info = detect(ProxyProtocolMode.REQUIRED, complete); + assertNotNull(info); + assertNotNull(info.getSource()); + assertNotNull(info.getDestination()); + + // ENABLED: give up on underflow and return null. + for (int i = 1; i < fullHeader.length; i++) { + Packet packet = TestPacketFactory.packetOf(slice(fullHeader, i)); + ProxyProtocolInfo enabledInfo = assertDoesNotThrow(() -> detect(ProxyProtocolMode.ENABLED, packet)); + assertNull(enabledInfo, "ENABLED: expected null (give up) at fragment size " + i); + assertEquals(0, packet.position(), "ENABLED: must not consume bytes when giving up"); + } + + Packet enabledComplete = TestPacketFactory.packetOf(fullHeader); + ProxyProtocolInfo enabledParsed = detect(ProxyProtocolMode.ENABLED, enabledComplete); + assertNotNull(enabledParsed); + assertNotNull(enabledParsed.getSource()); + assertNotNull(enabledParsed.getDestination()); + } + + /** + * This wrapper mirrors your mode behavior: + * - Underflow: REQUIRED rethrows, ENABLED swallows + * - ParseException: always becomes IOException (as per your snippet) + */ + private ProxyProtocolInfo detect(ProxyProtocolMode proxyProtocol, Packet packet) throws IOException { + ProxyProtocolInfo proxyProtocolInfo; + try { + proxyProtocolInfo = (proxyProtocol != ProxyProtocolMode.DISABLED) ? detectProxy(packet) : null; + } catch (BufferUnderflowException e) { + if (proxyProtocol == ProxyProtocolMode.REQUIRED) { + throw e; + } + return null; + } catch (ProxyProtocolParseException e) { + throw new IOException("Failed to parse proxy protocol", e); + } + return proxyProtocolInfo; + } + + /** + * Minimal detectProxy implementation for the test (uses your v1/v2 classes). + * IMPORTANT: must not consume bytes on non-match / underflow / parse failure. + */ + private ProxyProtocolInfo detectProxy(Packet packet) throws ProxyProtocolParseException, UnknownHostException { + ByteBuffer buffer = packet.getRawBuffer(); + buffer.mark(); + try { + ProxyProtocol v2 = new ProxyProtocolV2(); + if (v2.matches(packet)) { + return v2.parse(packet); + } + + ProxyProtocol v1 = new ProxyProtocolV1(); + if (v1.matches(packet)) { + return v1.parse(packet); + } + + buffer.reset(); + return null; + } catch (BufferUnderflowException e) { + buffer.reset(); + throw e; + } catch (ProxyProtocolParseException|RuntimeException e) { + buffer.reset(); + throw e; + } + } + + private static byte[] buildV1Header() { + // Valid v1 header (no payload needed for these tests) + String header = "PROXY TCP4 192.0.2.10 198.51.100.20 12345 443\r\n"; + return header.getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] buildV2HeaderTcp4() { + byte[] signature = new byte[]{ + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A + }; + + byte verCmd = 0x21; // ver=2, cmd=PROXY + byte protoFam = 0x11; // transport=STREAM (TCP), family=INET4 + short len = 12; // IPv4 addr block length + + byte[] srcIp = new byte[]{(byte) 192, 0, 2, 10}; + byte[] dstIp = new byte[]{(byte) 198, 51, 100, 20}; + short srcPort = (short) 12345; + short dstPort = (short) 443; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len).order(ByteOrder.BIG_ENDIAN); + buf.put(signature); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort(len); + buf.put(srcIp); + buf.put(dstIp); + buf.putShort(srcPort); + buf.putShort(dstPort); + return buf.array(); + } + + private static byte[] slice(byte[] input, int length) { + byte[] out = new byte[length]; + System.arraycopy(input, 0, out, 0, length); + return out; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1SpecEnforcementTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1SpecEnforcementTest.java new file mode 100644 index 000000000..5a09d73dc --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1SpecEnforcementTest.java @@ -0,0 +1,115 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.UnknownHostException; +import java.nio.BufferUnderflowException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolV1SpecEnforcementTest { + + @Test + void proxyEnabled_shortPacket_underflowDoesNotConsumeBytes_v1Probe() throws Exception { + ProxyProtocolMode proxyProtocol = ProxyProtocolMode.ENABLED; + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + Packet packet = TestPacketFactory.packetOf(new byte[]{'P', 'R', 'O'}); // < 6 bytes + int startPos = packet.position(); + + ProxyProtocolInfo proxyProtocolInfo = null; + try { + proxyProtocolInfo = detectProxy(packet, v1); + } catch (BufferUnderflowException e) { + if (proxyProtocol == ProxyProtocolMode.REQUIRED) { + throw e; + } + } catch (ProxyProtocolParseException e) { + throw new IOException("Failed to parse proxy protocol", e); + } + + assertNull(proxyProtocolInfo); + assertEquals(startPos, packet.position(), "Proxy detection must not advance buffer on underflow in ENABLED mode"); + } + + private ProxyProtocolInfo detectProxy(Packet packet, ProxyProtocol proxyProtocol) throws BufferUnderflowException, UnknownHostException, ProxyProtocolParseException { + if (proxyProtocol.matches(packet)) { + return proxyProtocol.parse(packet); + } + return null; + } + + @Test + void parse_acceptsProxyUnknown_consumesHeader_andReturnsNullEndpoints() throws Exception { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + String header = "PROXY UNKNOWN\r\n"; + byte[] payload = "PAYLOAD".getBytes(StandardCharsets.US_ASCII); + Packet packet = TestPacketFactory.packetOf(concat(header.getBytes(StandardCharsets.US_ASCII), payload)); + + int startPos = packet.position(); + + ProxyProtocolInfo info = v1.parse(packet); + + assertEquals("v1", info.getProtocolVersion()); + assertNull(info.getSource(), "PROXY UNKNOWN must not provide a source address"); + assertNull(info.getDestination(), "PROXY UNKNOWN must not provide a destination address"); + + int expectedPos = startPos + header.getBytes(StandardCharsets.US_ASCII).length; + assertEquals(expectedPos, packet.position(), "Packet position should advance past PROXY UNKNOWN header"); + } + + @Test + void parse_rejectsInvalidProtoToken() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + Packet packet = TestPacketFactory.packetOf( + "PROXY POTATO 192.0.2.10 198.51.100.20 12345 443\r\n" + .getBytes(StandardCharsets.US_ASCII) + ); + + assertThrows(ProxyProtocolParseException.class, () -> v1.parse(packet)); + } + + @Test + void parse_rejectsIfCrlfNotFoundWithinMaxLength() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + // Spec max line length is 107 bytes incl CRLF. + // This header is intentionally long and has no CRLF so parser must reject. + String tooLongNoCrlf = "PROXY TCP4 192.0.2.10 198.51.100.20 12345 443 " + + "EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA EXTRA"; + + Packet packet = TestPacketFactory.packetOf(tooLongNoCrlf.getBytes(StandardCharsets.US_ASCII)); + + assertThrows(ProxyProtocolParseException.class, () -> v1.parse(packet)); + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1Test.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1Test.java new file mode 100644 index 000000000..a4504e024 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV1Test.java @@ -0,0 +1,124 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.nio.BufferUnderflowException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolV1Test { + + @Test + void matches_returnsTrueForProxyPrefix() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + Packet packet = TestPacketFactory.packetOf("PROXY TCP4 1.2.3.4 5.6.7.8 12 34\r\nX".getBytes(StandardCharsets.US_ASCII)); + + assertTrue(v1.matches(packet)); + } + + @Test + void matches_returnsFalseForNonProxyPrefix() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + Packet packet = TestPacketFactory.packetOf("NOTPRO something\r\n".getBytes(StandardCharsets.US_ASCII)); + + assertFalse(v1.matches(packet)); + } + + @Test + void parse_happyPath_parsesAddressesAndConsumesHeader() throws Exception { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + String header = "PROXY TCP4 192.0.2.10 198.51.100.20 12345 443\r\n"; + byte[] payload = "PAYLOAD".getBytes(StandardCharsets.US_ASCII); + + byte[] bytes = concat(header.getBytes(StandardCharsets.US_ASCII), payload); + Packet packet = TestPacketFactory.packetOf(bytes); + + int startPos = packet.position(); + + ProxyProtocolInfo info = v1.parse(packet); + + assertEquals("v1", info.getProtocolVersion()); + + assertEquals("192.0.2.10", info.getSource().getHostString()); + assertEquals(12345, info.getSource().getPort()); + + assertEquals("198.51.100.20", info.getDestination().getHostString()); + assertEquals(443, info.getDestination().getPort()); + + int expectedPos = startPos + header.getBytes(StandardCharsets.US_ASCII).length; + assertEquals(expectedPos, packet.position(), "Packet position should be after the PROXY v1 header"); + } + + @Test + void parse_invalidHeader_throwsIllegalArgumentException() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + // Missing fields (needs at least 6 tokens) + Packet packet = TestPacketFactory.packetOf("PROXY TCP4 1.2.3.4\r\n".getBytes(StandardCharsets.US_ASCII)); + + ProxyProtocolParseException ex = assertThrows(ProxyProtocolParseException.class, () -> v1.parse(packet)); + assertTrue(ex.getMessage().contains("Invalid PROXY v1 header")); + } + + @Test + void parse_headerWithoutCrlf_throwsBufferUnderflowException() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + Packet packet = TestPacketFactory.packetOf( + "PROXY TCP4 1.2.3.4 5.6.7.8 123 456".getBytes(StandardCharsets.US_ASCII) + ); + + assertThrows(BufferUnderflowException.class, () -> v1.parse(packet)); + } + + @Test + void parse_headerExceedsMaxWithoutCrlf_throwsProxyProtocolParseException() { + ProxyProtocolV1 v1 = new ProxyProtocolV1(); + + byte[] bytes = new byte[108]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = 'A'; + } + // Starts with PROXY so parse is attempted, but no CRLF and we hit max length + bytes[0] = 'P'; + bytes[1] = 'R'; + bytes[2] = 'O'; + bytes[3] = 'X'; + bytes[4] = 'Y'; + bytes[5] = ' '; + + Packet packet = TestPacketFactory.packetOf(bytes); + + assertThrows(ProxyProtocolParseException.class, () -> v1.parse(packet)); + } + + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2LengthLimitTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2LengthLimitTest.java new file mode 100644 index 000000000..5b9183802 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2LengthLimitTest.java @@ -0,0 +1,93 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolV2LengthLimitTest { + + private static final byte[] SIGNATURE = { + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A + }; + + private static final int MAX_PROXY_DATA_LENGTH = 512; + + @Test + void parse_rejectsLengthGreaterThanMax() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + byte verCmd = 0x21; // ver=2, cmd=PROXY + byte protoFam = 0x00; // transport=UNSPEC, family=UNSPEC + + int len = MAX_PROXY_DATA_LENGTH + 1; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + + Packet packet = TestPacketFactory.packetOf(buf.array()); + + ProxyProtocolParseException ex = assertThrows(ProxyProtocolParseException.class, () -> v2.parse(packet)); + assertTrue(ex.getMessage().contains("length too large"), ex.getMessage()); + } + + @Test + void parse_acceptsLengthEqualToMax_forUnspecFamilyAndReturnsNullEndpoints() throws Exception { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + byte verCmd = 0x21; // ver=2, cmd=PROXY + byte protoFam = 0x00; // transport=UNSPEC, family=UNSPEC + + int len = MAX_PROXY_DATA_LENGTH; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len + 3).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + + // Fill the data block with deterministic bytes (treated as TLVs/opaque when family=UNSPEC) + for (int i = 0; i < len; i++) { + buf.put((byte) (i & 0xFF)); + } + + // Payload after PROXY block + buf.put(new byte[]{0x01, 0x02, 0x03}); + + Packet packet = TestPacketFactory.packetOf(buf.array()); + int startPos = packet.position(); + + ProxyProtocolInfo info = v2.parse(packet); + + assertNull(info.getSource()); + assertNull(info.getDestination()); + + int expectedAdvance = 12 + 1 + 1 + 2 + len; + assertEquals(startPos + expectedAdvance, packet.position(), "Packet position should advance past v2 header + len"); + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2SpecEnforcementTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2SpecEnforcementTest.java new file mode 100644 index 000000000..60ba8d6c1 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2SpecEnforcementTest.java @@ -0,0 +1,106 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolV2SpecEnforcementTest { + + private static final byte[] SIGNATURE = { + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A + }; + + @Test + void parse_localCommand_returnsNullEndpoints_andConsumesProxyBlock() throws Exception { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + // ver=2, cmd=LOCAL (0x0) + byte verCmd = 0x20; + + // Transport/family can be UNSPEC for LOCAL; spec says ignore address info. + byte protoFam = 0x00; + + int len = 0; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len + 1).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + buf.put((byte) 0x7A); // payload + + Packet packet = TestPacketFactory.packetOf(buf.array()); + int startPos = packet.position(); + + ProxyProtocolInfo info = v2.parse(packet); + + assertNull(info.getSource(), "LOCAL command must not provide a proxied source"); + assertNull(info.getDestination(), "LOCAL command must not provide a proxied destination"); + + int expectedAdvance = 12 + 1 + 1 + 2 + len; + assertEquals(startPos + expectedAdvance, packet.position(), "Packet position should be after the PROXY v2 fixed header + len"); + } + + @Test + void parse_acceptsAfUnspec_withTlvOnly_returnsNullEndpoints_andSkipsTlv() throws Exception { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + // ver=2, cmd=PROXY (0x1) + byte verCmd = 0x21; + + // transport=UNSPEC (0x0), family=UNSPEC (0x0) + byte protoFam = 0x00; + + // Provide TLV only: type=0x01, length=0x0003, value=0xAA 0xBB 0xCC + byte tlvType = 0x01; + short tlvLen = 3; + + int len = 1 + 2 + tlvLen; // type + length + value + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len + 2).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + + buf.put(tlvType); + buf.putShort(tlvLen); + buf.put(new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC}); + + buf.put(new byte[]{0x11, 0x22}); // payload after TLV + + Packet packet = TestPacketFactory.packetOf(buf.array()); + int startPos = packet.position(); + + ProxyProtocolInfo info = v2.parse(packet); + + assertNull(info.getSource(), "AF_UNSPEC must not require address parsing"); + assertNull(info.getDestination(), "AF_UNSPEC must not require address parsing"); + + int expectedAdvance = 12 + 1 + 1 + 2 + len; + assertEquals(startPos + expectedAdvance, packet.position(), "Packet position should advance past TLVs"); + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2Test.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2Test.java new file mode 100644 index 000000000..f84272693 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/ProxyProtocolV2Test.java @@ -0,0 +1,153 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class ProxyProtocolV2Test { + + private static final byte[] SIGNATURE = { + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A + }; + + @Test + void matches_returnsTrueForValidSignature() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + byte[] bytes = concat(SIGNATURE, new byte[]{0x21, 0x11, 0x00, 0x00}); // minimal header after signature + Packet packet = TestPacketFactory.packetOf(bytes); + + assertTrue(v2.matches(packet)); + } + + @Test + void matches_returnsFalseForShortPacket() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + Packet packet = TestPacketFactory.packetOf(new byte[]{0x01, 0x02, 0x03}); + + assertFalse(v2.matches(packet)); + } + + @Test + void matches_returnsFalseForWrongSignature() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + byte[] badSig = Arrays.copyOf(SIGNATURE, SIGNATURE.length); + badSig[0] = 0x00; + + byte[] bytes = concat(badSig, new byte[]{0x21, 0x11, 0x00, 0x00}); + Packet packet = TestPacketFactory.packetOf(bytes); + + assertFalse(v2.matches(packet)); + } + + @Test + void parse_happyPath_ipv4Tcp_parsesAddressesAndConsumesProxyBlock() throws Exception { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + byte verCmd = 0x21; // version 2 (0x2), command PROXY (0x1) + byte protoFam = 0x11; // transport=1 (tcp), family=1 (ipv4) + int len = 12; // ipv4: src(4)+dst(4)+srcPort(2)+dstPort(2) + + byte[] srcIp = {(byte) 192, 0, 2, 10}; + byte[] dstIp = {(byte) 198, 51, 100, 20}; + int srcPort = 12345; + int dstPort = 443; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len + 3).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + + buf.put(srcIp); + buf.put(dstIp); + buf.putShort((short) srcPort); + buf.putShort((short) dstPort); + + // payload bytes after PROXY block + buf.put(new byte[]{0x7A, 0x7B, 0x7C}); + + Packet packet = TestPacketFactory.packetOf(buf.array()); + int startPos = packet.position(); + + ProxyProtocolInfo info = v2.parse(packet); + + assertEquals("tcp4", info.getProtocolVersion()); + assertEquals("192.0.2.10", info.getSource().getAddress().getHostAddress()); + assertEquals(srcPort, info.getSource().getPort()); + assertEquals("198.51.100.20", info.getDestination().getAddress().getHostAddress()); + assertEquals(dstPort, info.getDestination().getPort()); + + int expectedAdvance = 12 + 1 + 1 + 2 + len; + assertEquals(startPos + expectedAdvance, packet.position(), "Packet position should be after the PROXY v2 block"); + } + + @Test + void parse_invalidVersionNibble_throwsIllegalArgumentException() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + byte verCmd = 0x11; // version nibble = 1 (not 2) + byte protoFam = 0x11; + int len = 12; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + buf.put(new byte[len]); + + Packet packet = TestPacketFactory.packetOf(buf.array()); + + assertThrows(ProxyProtocolParseException.class, () -> v2.parse(packet)); + } + + @Test + void parse_unsupportedFamily_throwsProxyProtocolParseException() { + ProxyProtocolV2 v2 = new ProxyProtocolV2(); + + byte verCmd = 0x21; + byte protoFam = 0x13; // transport=1 (tcp), family=3 (unsupported in your parser) + int len = 0; + + ByteBuffer buf = ByteBuffer.allocate(12 + 1 + 1 + 2 + len).order(ByteOrder.BIG_ENDIAN); + buf.put(SIGNATURE); + buf.put(verCmd); + buf.put(protoFam); + buf.putShort((short) len); + + Packet packet = TestPacketFactory.packetOf(buf.array()); + + assertThrows(ProxyProtocolParseException.class, () -> v2.parse(packet)); + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/TestPacketFactory.java b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/TestPacketFactory.java new file mode 100644 index 000000000..83cd16f8f --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/proxy/TestPacketFactory.java @@ -0,0 +1,90 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.proxy; + +import io.mapsmessaging.network.io.Packet; + +import java.lang.reflect.Constructor; +import java.nio.ByteBuffer; + +final class TestPacketFactory { + + private TestPacketFactory() { + } + + static Packet packetOf(byte[] bytes) { + try { + return tryConstruct(bytes); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to construct Packet for tests. Add a Packet ctor for ByteBuffer or byte[] (or update TestPacketFactory).", e); + } + } + + private static Packet tryConstruct(byte[] bytes) throws ReflectiveOperationException { + Class packetClass = Packet.class; + + // 1) Packet(ByteBuffer) + for (Constructor ctor : packetClass.getDeclaredConstructors()) { + Class[] params = ctor.getParameterTypes(); + if (params.length == 1 && ByteBuffer.class.isAssignableFrom(params[0])) { + ctor.setAccessible(true); + return (Packet) ctor.newInstance(ByteBuffer.wrap(bytes)); + } + } + + // 2) Packet(byte[]) + for (Constructor ctor : packetClass.getDeclaredConstructors()) { + Class[] params = ctor.getParameterTypes(); + if (params.length == 1 && params[0].isArray() && params[0].getComponentType() == byte.class) { + ctor.setAccessible(true); + return (Packet) ctor.newInstance((Object) bytes); + } + } + + // 3) Packet(byte[], int, int) or Packet(byte[], int, int, ...) + for (Constructor ctor : packetClass.getDeclaredConstructors()) { + Class[] params = ctor.getParameterTypes(); + if (params.length >= 3 + && params[0].isArray() + && params[0].getComponentType() == byte.class + && params[1] == int.class + && params[2] == int.class) { + ctor.setAccessible(true); + + Object[] args = new Object[params.length]; + args[0] = bytes; + args[1] = 0; + args[2] = bytes.length; + for (int i = 3; i < params.length; i++) { + if (params[i] == int.class) { + args[i] = 0; + } else if (params[i] == boolean.class) { + args[i] = false; + } else { + args[i] = null; + } + } + return (Packet) ctor.newInstance(args); + } + } + + throw new NoSuchMethodException("No suitable Packet constructor found for ByteBuffer/byte[] input."); + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingPipelineTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingPipelineTest.java index 26e0d2999..9337f00fc 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingPipelineTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingPipelineTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingTest.java index e9832a303..aaf87f7b6 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/PackingTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteMessageFactoryRebuilderTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteMessageFactoryRebuilderTest.java index a714e167d..c278c5330 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteMessageFactoryRebuilderTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteMessageFactoryRebuilderTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -19,9 +19,7 @@ package io.mapsmessaging.network.protocol.impl.satellite; -import io.mapsmessaging.network.protocol.impl.satellite.protocol.SatelliteMessage; -import io.mapsmessaging.network.protocol.impl.satellite.protocol.SatelliteMessageFactory; -import io.mapsmessaging.network.protocol.impl.satellite.protocol.SatelliteMessageRebuilder; +import io.mapsmessaging.network.protocol.impl.satellite.protocol.*; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -35,25 +33,34 @@ private static byte[] payload(int size, byte seed) { return p; } + private static List shuffledCopy(List input) { + List shuffled = new ArrayList<>(input); + Collections.shuffle(shuffled, new Random(1234567L)); + return shuffled; + } + @Test void splitAndReassemble_inOrder() { - int streamId = 42; byte[] src = payload(10_000, (byte) 0x5A); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, 1024, true, (byte)0); - - // basic properties + List chunks = SatelliteMessageFactory.createMessages(src, 1024, true, (byte) 0); + int streamId = chunks.get(0).getStreamNumber(); Assertions.assertTrue(chunks.size() >= 10); - Assertions.assertEquals(streamId, chunks.get(0).getStreamNumber()); Assertions.assertTrue(chunks.stream().allMatch(SatelliteMessage::isCompressed)); - // packet numbers count down to zero - int expected = chunks.size() - 1; + int totalPackets = chunks.get(0).getTotalPackets(); + Assertions.assertEquals(chunks.size(), totalPackets); + Assertions.assertTrue(chunks.stream().allMatch(m -> m.getTotalPackets() == totalPackets)); + + boolean[] seen = new boolean[totalPackets + 1]; for (SatelliteMessage m : chunks) { - Assertions.assertEquals(expected--, m.getPacketNumber()); + int pn = m.getPacketNumber(); + Assertions.assertTrue(pn >= 0 && pn <= totalPackets); + seen[pn] = true; + } + for (int i = 0; i < totalPackets; i++) { + Assertions.assertTrue(seen[i], "Missing packetNumber=" + i); } - Assertions.assertEquals(0, chunks.get(chunks.size() - 1).getPacketNumber()); - // reconstruct from in-order list SatelliteMessage combined = SatelliteMessageFactory.reconstructMessage(chunks); Assertions.assertNotNull(combined); Assertions.assertEquals(streamId, combined.getStreamNumber()); @@ -63,16 +70,21 @@ void splitAndReassemble_inOrder() { @Test void splitBoundary_exactMultipleChunkSizes() { - int streamId = 7; int chunkSize = 512; byte[] src = payload(chunkSize * 8, (byte) 0x33); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, chunkSize, false, (byte)0); + List chunks = SatelliteMessageFactory.createMessages(src, chunkSize, false, (byte) 0); Assertions.assertEquals(8, chunks.size()); Assertions.assertFalse(chunks.get(0).isCompressed()); + + int totalPackets = chunks.get(0).getTotalPackets(); + Assertions.assertEquals(8, totalPackets); + for (SatelliteMessage m : chunks) { Assertions.assertEquals(chunkSize, m.getMessage().length); + Assertions.assertTrue(m.getPacketNumber() >= 0 && m.getPacketNumber() < totalPackets); } + SatelliteMessage combined = SatelliteMessageFactory.reconstructMessage(chunks); Assertions.assertArrayEquals(src, combined.getMessage()); Assertions.assertFalse(combined.isCompressed()); @@ -80,68 +92,149 @@ void splitBoundary_exactMultipleChunkSizes() { @Test void singleUncompressedFastPath() { - int streamId = 9; byte[] src = payload(400, (byte) 0x01); - List msgs = SatelliteMessageFactory.createMessages(streamId, src, 1000, false, (byte)0); + List msgs = SatelliteMessageFactory.createMessages(src, 1000, false, (byte) 0); + Assertions.assertEquals(1, msgs.size()); - SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); + SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); SatelliteMessage out = rb.rebuild(msgs.get(0)); + Assertions.assertNotNull(out); - Assertions.assertSame(msgs.get(0), out); // fast path returns original + Assertions.assertSame(msgs.get(0), out); Assertions.assertArrayEquals(src, out.getMessage()); Assertions.assertFalse(out.isCompressed()); } @Test void rebuilder_inOrderArrival() { - int streamId = 11; byte[] src = payload(5_000, (byte) 0x22); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, 700, true, (byte)0); + List chunks = SatelliteMessageFactory.createMessages(src, 700, true, (byte) 0); SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); SatelliteMessage result = null; + for (SatelliteMessage m : chunks) { SatelliteMessage r = rb.rebuild(m); - if (r != null) result = r; + if (r != null) { + result = r; + } } + Assertions.assertNotNull(result); Assertions.assertArrayEquals(src, result.getMessage()); Assertions.assertTrue(result.isCompressed()); } @Test - void rebuilder_outOfOrderArrival_currentImplLosesData() { - // Demonstrates current behavior with out-of-order arrival: last chunk first triggers premature reconstruct. - int streamId = 15; + void rebuilder_outOfOrderArrival() { byte[] src = payload(3_000, (byte) 0x6E); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, 512, true, (byte)0); + List chunks = SatelliteMessageFactory.createMessages(src, 512, true, (byte) 0); - // Shuffle so that packetNumber==0 arrives first - List shuffled = new ArrayList<>(chunks); - shuffled.sort(Comparator.comparingInt(SatelliteMessage::getPacketNumber)); // 0,1,2,... - Collections.swap(shuffled, 0, shuffled.size() - 1); // move 0 to front + List shuffled = shuffledCopy(chunks); SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); - SatelliteMessage first = rb.rebuild(shuffled.get(0)); // packetNumber==0 - // Current code returns a prematurely reconstructed message (only last chunk); prove it's not equal. - Assertions.assertNull(first); - - // Feed the rest; current impl won’t fix the already-returned, but ensure we at least don't throw. SatelliteMessage finished = null; - for (int i = 1; i < shuffled.size(); i++) { - finished = rb.rebuild(shuffled.get(i)); + + for (SatelliteMessage m : shuffled) { + SatelliteMessage r = rb.rebuild(m); + if (r != null) { + finished = r; + } } + Assertions.assertNotNull(finished); Assertions.assertEquals(src.length, finished.getMessage().length); - Assertions.assertArrayEquals(src,finished.getMessage()); + Assertions.assertArrayEquals(src, finished.getMessage()); + } + + @Test + void reconstructMessage_acceptsShuffledInput() { + byte[] src = payload(6_000, (byte) 0x4B); + List chunks = SatelliteMessageFactory.createMessages(src, 777, true, (byte) 0); + + List shuffled = shuffledCopy(chunks); + + SatelliteMessage combined = SatelliteMessageFactory.reconstructMessage(shuffled); + Assertions.assertNotNull(combined); + Assertions.assertArrayEquals(src, combined.getMessage()); + } + + @Test + void rebuilder_missingPacket_neverEmits() { + byte[] src = payload(12_000, (byte) 0x19); + List chunks = SatelliteMessageFactory.createMessages(src, 900, true, (byte) 0); + Assertions.assertTrue(chunks.size() >= 3); + + List working = new ArrayList<>(chunks); + working.remove(working.size() / 2); // drop a middle packet + List shuffled = shuffledCopy(working); + + SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); + for (SatelliteMessage m : shuffled) { + SatelliteMessage out = rb.rebuild(m); + Assertions.assertNull(out, "Should not emit when a packet is missing"); + } + } + + @Test + void rebuilder_duplicatePacket_sameContent_stillReassembles() { + byte[] src = payload(8_000, (byte) 0x2C); + List chunks = SatelliteMessageFactory.createMessages(src, 800, true, (byte) 0); + + List list = new ArrayList<>(chunks); + + SatelliteMessage dup = chunks.get(chunks.size() / 2); + list.add(dup); + + List shuffled = shuffledCopy(list); + + SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); + SatelliteMessage finished = null; + + for (SatelliteMessage m : shuffled) { + SatelliteMessage out = rb.rebuild(m); + if (out != null) { + finished = out; + } + } + + Assertions.assertNotNull(finished); + Assertions.assertArrayEquals(src, finished.getMessage()); + } + + @Test + void rebuilder_multipleStreamsInterleaved_reassemblesBoth() { + + byte[] srcA = payload(7_500, (byte) 0x11); + byte[] srcB = payload(9_200, (byte) 0x55); + + List chunksA = SatelliteMessageFactory.createMessages( srcA, 700, true, (byte) 0); + List chunksB = SatelliteMessageFactory.createMessages( srcB, 650, true, (byte) 0); + + List mixed = new ArrayList<>(chunksA.size() + chunksB.size()); + mixed.addAll(chunksA); + mixed.addAll(chunksB); + Collections.shuffle(mixed, new Random(7654321L)); + + SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); + + Map outputs = new HashMap<>(); + for (SatelliteMessage m : mixed) { + SatelliteMessage out = rb.rebuild(m); + if (out != null) { + outputs.put(out.getStreamNumber(), out); + } + } + + Assertions.assertEquals(2, outputs.size()); + } @Test void compressedFlagPreservedThroughRebuild() { - int streamId = 20; byte[] src = payload(2_048, (byte) 0x13); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, 600, true,(byte)0); + List chunks = SatelliteMessageFactory.createMessages(src, 600, true, (byte) 0); SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); SatelliteMessage out = null; @@ -149,22 +242,122 @@ void compressedFlagPreservedThroughRebuild() { SatelliteMessage r = rb.rebuild(m); if (r != null) out = r; } + Assertions.assertNotNull(out); Assertions.assertTrue(out.isCompressed()); } @Test void clearResetsState() { - int streamId = 25; byte[] src = payload(1_500, (byte) 0x55); - List chunks = SatelliteMessageFactory.createMessages(streamId, src, 500, true,(byte)0); + List chunks = SatelliteMessageFactory.createMessages( src, 500, true, (byte) 0); SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); rb.rebuild(chunks.get(0)); rb.clear(); - // After clear, feeding last chunk should reconstruct only that chunk (current impl behavior) - SatelliteMessage only = rb.rebuild(chunks.get(chunks.size() - 1)); - Assertions.assertNotNull(only); - Assertions.assertNotEquals(src.length, only.getMessage().length); + + List shuffled = shuffledCopy(chunks); + SatelliteMessage finished = null; + for (SatelliteMessage m : shuffled) { + SatelliteMessage r = rb.rebuild(m); + if (r != null) finished = r; + } + + Assertions.assertNotNull(finished); + Assertions.assertArrayEquals(src, finished.getMessage()); } + + @Test + void multiStream_packFragmentShuffleRebuildUnpack_validatesPayloads() throws Exception { + + Map> eventMapA = new LinkedHashMap<>(); + eventMapA.put("/a/one", List.of(payload(1200, (byte) 0x01), payload(800, (byte) 0x02))); + eventMapA.put("/a/two", List.of(payload(50, (byte) 0x03))); + eventMapA.put("/a/three", List.of(payload(2048, (byte) 0x04), payload(17, (byte) 0x05), payload(600, (byte) 0x06))); + + Map> eventMapB = new LinkedHashMap<>(); + eventMapB.put("/b/one", List.of(payload(333, (byte) 0x11), payload(444, (byte) 0x12), payload(555, (byte) 0x13))); + eventMapB.put("/b/two", List.of(payload(4096, (byte) 0x14))); + eventMapB.put("/b/three", List.of(payload(1, (byte) 0x15), payload(2, (byte) 0x16))); + + CipherManager cipherManager = null; + int compressionThreshold = 1; + int maxFragmentSize = 600; + byte transformationId = (byte) 7; + + MessageQueuePacker.Packed packedA = MessageQueuePacker.pack(eventMapA, compressionThreshold, cipherManager, null); + MessageQueuePacker.Packed packedB = MessageQueuePacker.pack(eventMapB, compressionThreshold, cipherManager, null); + + Assertions.assertTrue(packedA.compressed()); + Assertions.assertTrue(packedB.compressed()); + Assertions.assertEquals(transformationId, (byte) transformationId); + + List messagesA = SatelliteMessageFactory.createMessages(packedA.data(), maxFragmentSize, packedA.compressed(), transformationId); + List messagesB = SatelliteMessageFactory.createMessages(packedB.data(), maxFragmentSize, packedB.compressed(), transformationId); + + int streamA = messagesA.get(0).getStreamNumber(); + int streamB = messagesB.get(0).getStreamNumber(); + Assertions.assertNotEquals(streamA, streamB, "Streams must be unique for concurrent messages"); + + for (SatelliteMessage m : messagesA) { + Assertions.assertEquals(streamA, m.getStreamNumber()); + Assertions.assertEquals(messagesA.size(), m.getTotalPackets()); + Assertions.assertEquals(transformationId, m.getTransformationId()); + Assertions.assertTrue(m.isCompressed()); + } + + for (SatelliteMessage m : messagesB) { + Assertions.assertEquals(streamB, m.getStreamNumber()); + Assertions.assertEquals(messagesB.size(), m.getTotalPackets()); + Assertions.assertEquals(transformationId, m.getTransformationId()); + Assertions.assertTrue(m.isCompressed()); + } + + List interleaved = new ArrayList<>(messagesA.size() + messagesB.size()); + interleaved.addAll(messagesA); + interleaved.addAll(messagesB); + Collections.shuffle(interleaved, new Random(0xBADC0FFEL)); + + SatelliteMessageRebuilder rb = new SatelliteMessageRebuilder(); + + Map rebuilt = new HashMap<>(); + for (SatelliteMessage m : interleaved) { + SatelliteMessage out = rb.rebuild(m); + if (out != null) { + rebuilt.put(out.getStreamNumber(), out); + } + } + + Assertions.assertEquals(2, rebuilt.size()); + SatelliteMessage rebuiltA = rebuilt.get(streamA); + SatelliteMessage rebuiltB = rebuilt.get(streamB); + Assertions.assertNotNull(rebuiltA); + Assertions.assertNotNull(rebuiltB); + + Assertions.assertEquals(transformationId, rebuiltA.getTransformationId()); + Assertions.assertEquals(transformationId, rebuiltB.getTransformationId()); + Assertions.assertTrue(rebuiltA.isCompressed()); + Assertions.assertTrue(rebuiltB.isCompressed()); + + Map> unpackedA = MessageQueueUnpacker.unpack(rebuiltA.getMessage(), rebuiltA.isCompressed(), cipherManager); + Map> unpackedB = MessageQueueUnpacker.unpack(rebuiltB.getMessage(), rebuiltB.isCompressed(), cipherManager); + + assertEventMapsEqual(eventMapA, unpackedA); + assertEventMapsEqual(eventMapB, unpackedB); + } + + private static void assertEventMapsEqual(Map> expected, Map> actual) { + Assertions.assertEquals(expected.keySet(), actual.keySet(), "Topic set differs"); + for (Map.Entry> entry : expected.entrySet()) { + String topic = entry.getKey(); + List expList = entry.getValue(); + List actList = actual.get(topic); + Assertions.assertNotNull(actList, "Missing topic: " + topic); + Assertions.assertEquals(expList.size(), actList.size(), "Event count differs for topic: " + topic); + for (int i = 0; i < expList.size(); i++) { + Assertions.assertArrayEquals(expList.get(i), actList.get(i), "Payload differs for topic: " + topic + " idx=" + i); + } + } + } + } diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationReverseTests.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationReverseTests.java new file mode 100644 index 000000000..7c7f3d093 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationReverseTests.java @@ -0,0 +1,348 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.satellite; + +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.*; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +class SatelliteReplicationReverseTests extends BaseTestConfig { + + private static boolean isInitialised = false; + + // ----------------------------- + // REST/Enterprise -> Modem topics + // ----------------------------- + // + // Replace these once you confirm your reverse mapping endpoints. + // + // Example idea (NOT assumed correct): + // ENTERPRISE_TO_MODEM_TELEMETRY_BASE might be: "/000.../maps/out/satellite" + // MODEM_RECEIVE_TELEMETRY_BASE might be: "/sensors" + // + private static final String ENTERPRISE_BASE = "/000000000SKYEE3D"; + + private static final String ENTERPRISE_TO_MODEM_TELEMETRY_BASE = ENTERPRISE_BASE + "/maps/out/satellite"; + private static final String MODEM_RECEIVE_TELEMETRY_BASE = "/satellite"; + + // Raw reverse mapping endpoints (you will replace these): + // Enterprise publishes raw -> modem receives at SIN/MIN request topic. + private static final String ENTERPRISE_TO_MODEM_RAW_PUBLISH_TEMPLATE = ENTERPRISE_BASE + "/common/out/%d/%d"; + private static final String MODEM_RAW_RECEIVE_TEMPLATE = "/incoming/%d/%d"; + + private static final Duration OUTBOUND_POLL_INTERVAL = Duration.ofSeconds(2); + private static final Duration INBOUND_POLL_INTERVAL = Duration.ofSeconds(5); + private static final Duration WAIT = computeWait(OUTBOUND_POLL_INTERVAL, INBOUND_POLL_INTERVAL); + + private final String runId = UUID.randomUUID().toString(); + + private Session modemSideSession; + private Session enterpriseSideSession; + + @BeforeEach + void checkForLinkUp(){ + if(!isInitialised){ + delay(5000); // allow the server to establish web connection + isInitialised = true; + } + } + + @AfterEach + void cleanup() { + closeQuietly(modemSideSession); + closeQuietly(enterpriseSideSession); + modemSideSession = null; + enterpriseSideSession = null; + } + + @Test + void dropSemanticsOnlyLastEventPerTopicIsObservedEnterpriseToModem() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + String enterprisePublishTopic = enterpriseToModemSatelliteTopic(1); + String modemReceiveTopic = modemSensorsTopic(1); + + SubscriptionContext subscription = modemReceiver.subscribe(modemSideSession, modemReceiveTopic); + + int publishCount = 25; + for (int index = 0; index < publishCount; index++) { + publishText(enterpriseSideSession, enterprisePublishTopic, "msg-" + index); + } + + boolean received = modemReceiver.await(modemReceiveTopic, WAIT); + Assertions.assertTrue(received, "Expected at least one replicated event within " + WAIT); + + String lastPayload = modemReceiver.getLastText(modemReceiveTopic); + Assertions.assertEquals("msg-" + (publishCount - 1), lastPayload, "Only the last event should be observed"); + + modemSideSession.removeSubscription(subscription.getKey()); + } + + @Test + void tenTopicsProduceTenEventsOnePerTopicEnterpriseToModem() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + int topicCount = 10; + SubscriptionContext[] subscriptions = new SubscriptionContext[topicCount]; + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + subscriptions[topicIndex] = modemReceiver.subscribe(modemSideSession, modemSensorsTopic(topicIndex)); + } + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + String enterprisePublishTopic = enterpriseToModemSatelliteTopic(topicIndex); + + for (int messageIndex = 0; messageIndex < 7; messageIndex++) { + publishText(enterpriseSideSession, enterprisePublishTopic, "t" + topicIndex + "-m" + messageIndex); + } + } + + boolean allReceived = modemReceiver.awaitAll(topicCount, WAIT); + Assertions.assertTrue(allReceived, "Expected 1 event per topic (" + topicCount + ") within " + WAIT); + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + String topic = modemSensorsTopic(topicIndex); + String lastPayload = modemReceiver.getLastText(topic); + Assertions.assertEquals("t" + topicIndex + "-m6", lastPayload, "Last-per-topic must hold for " + topic); + } + + for (SubscriptionContext subscription : subscriptions) { + modemSideSession.removeSubscription(subscription.getKey()); + } + } + + @Test + void rawNanoIoTPacketsAreRoutedUsingSinMinEnterpriseToModem() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + int sin = 20; + int min = 7; + + String enterprisePublishTopic = enterpriseToModemRawPublishTopic(sin, min); + String modemReceiveTopic = modemRawReceiveTopic(sin, min); + + SubscriptionContext subscription = modemReceiver.subscribe(modemSideSession, modemReceiveTopic); + + byte[] raw = buildNanoIoTRawPacket(sin, min, "hello-nano"); + publishRaw(enterpriseSideSession, enterprisePublishTopic, raw); + + boolean received = modemReceiver.await(modemReceiveTopic, WAIT); + Assertions.assertTrue(received, "Expected raw packet arrival within " + WAIT); + + byte[] observed = modemReceiver.getLastBytes(modemReceiveTopic); + Assertions.assertNotNull(observed, "Expected payload bytes"); + Assertions.assertTrue(observed.length >= 2, "Raw payload must be at least 2 bytes"); + + String tail = new String(observed, StandardCharsets.UTF_8); + Assertions.assertEquals("hello-nano", tail, "Raw payload tail must be preserved"); + + modemSideSession.removeSubscription(subscription.getKey()); + } + + private Session createModemSideSession(MessageListener listener) throws LoginException, IOException { + return createSession("SatelliteReverseModemSide" + System.nanoTime(), 60, 60, false, listener); + } + + private Session createEnterpriseSideSession(MessageListener listener) throws LoginException, IOException { + return createSession("SatelliteReverseEnterpriseSide" + System.nanoTime(), 60, 60, false, listener); + } + + private static void publishText(Session session, String topic, String payload) throws Exception { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(payload, "payload"); + + publishRaw(session, topic, payload.getBytes(StandardCharsets.UTF_8)); + } + + private static void publishRaw(Session session, String topic, byte[] payload) throws Exception { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(payload, "payload"); + + MessageBuilder messageBuilder = new MessageBuilder(); + Message message = messageBuilder.setOpaqueData(payload).build(); + + session.findDestination(topic, DestinationType.TOPIC).get().storeMessage(message); + } + + private static byte[] buildNanoIoTRawPacket(int sin, int min, String tailText) { + byte[] tailBytes = tailText.getBytes(StandardCharsets.UTF_8); + ByteBuffer buffer = ByteBuffer.allocate(2 + tailBytes.length); + buffer.put((byte) (sin & 0xFF)); + buffer.put((byte) (min & 0xFF)); + buffer.put(tailBytes); + return buffer.array(); + } + + private String modemSensorsTopic(int topicIndex) { + return MODEM_RECEIVE_TELEMETRY_BASE + "/" + runId + "/t/" + topicIndex; + } + + private String enterpriseToModemSatelliteTopic(int topicIndex) { + return ENTERPRISE_TO_MODEM_TELEMETRY_BASE + "/" + runId + "/t/" + topicIndex; + } + + private String enterpriseToModemRawPublishTopic(int sin, int min) { + return String.format(ENTERPRISE_TO_MODEM_RAW_PUBLISH_TEMPLATE, sin, min); + } + + private String modemRawReceiveTopic(int sin, int min) { + return String.format(MODEM_RAW_RECEIVE_TEMPLATE, sin, min); + } + + private void closeQuietly(Session session) { + if (session == null) { + return; + } + try { + close(session); + } catch (Exception ignored) { + } + } + + private static Duration computeWait(Duration outboundPoll, Duration inboundPoll) { + Duration slowest = outboundPoll.compareTo(inboundPoll) > 0 ? outboundPoll : inboundPoll; + + long scaledSeconds = slowest.toSeconds() * 8L; + if (scaledSeconds < 30L) { + scaledSeconds = 30L; + } + if (scaledSeconds > 120L) { + scaledSeconds = 120L; + } + return Duration.ofSeconds(scaledSeconds); + } + + private static class TestReceiver { + + private final Map lastByTopic; + private final Map latchByTopic; + + private TestReceiver() { + this.lastByTopic = new ConcurrentHashMap<>(); + this.latchByTopic = new ConcurrentHashMap<>(); + } + + private MessageListener listener() { + return messageEvent -> { + String destination = messageEvent.getDestinationName(); + Message message = messageEvent.getMessage(); + + if (destination != null && message != null) { + lastByTopic.put(destination, message); + + CountDownLatch latch = latchByTopic.get(destination); + if (latch != null) { + latch.countDown(); + } + } + + messageEvent.getCompletionTask().run(); + }; + } + + private SubscriptionContext subscribe(Session session, String topic) throws Exception { + latchByTopic.put(topic, new CountDownLatch(1)); + + SubscriptionContextBuilder builder = new SubscriptionContextBuilder(topic, ClientAcknowledgement.AUTO); + SubscriptionContext context = builder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + return context; + } + + private boolean await(String topic, Duration timeout) throws InterruptedException { + CountDownLatch latch = latchByTopic.get(topic); + if (latch == null) { + throw new IllegalStateException("No latch registered for topic " + topic); + } + return latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private boolean awaitAll(int expectedTopics, Duration timeout) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + for (CountDownLatch latch : latchByTopic.values()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + if (!latch.await(remaining, TimeUnit.NANOSECONDS)) { + return false; + } + } + return lastByTopic.size() >= expectedTopics; + } + + private String getLastText(String topic) { + Message message = lastByTopic.get(topic); + Assertions.assertNotNull(message, "No message recorded for topic " + topic); + + byte[] bytes = message.getOpaqueData(); + Assertions.assertNotNull(bytes, "Message has null payload for " + topic); + + return new String(bytes, StandardCharsets.UTF_8); + } + + private byte[] getLastBytes(String topic) { + Message message = lastByTopic.get(topic); + Assertions.assertNotNull(message, "No message recorded for topic " + topic); + + byte[] bytes = message.getOpaqueData(); + Assertions.assertNotNull(bytes, "Message has null payload for " + topic); + + return bytes; + } + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationTests.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationTests.java new file mode 100644 index 000000000..338b926be --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/SatelliteReplicationTests.java @@ -0,0 +1,572 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.satellite; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class SatelliteReplicationTests extends BaseTestConfig { + + private static final String ENTERPRISE_BASE = "/000000000SKYEE3D/maps/in"; + + private static final String MODEM_TELEMETRY_BASE = "/sensors"; + private static final String ENTERPRISE_TELEMETRY_BASE = ENTERPRISE_BASE + "/satellite"; + + private static final String MODEM_RAW_REQUEST_TEMPLATE = "/outbound/%d/%d"; + + // NOTE: your config example/default says "/outbound" but field value says "/outgoing". + // This test uses "/outgoing" because that's what you showed as the actual value. + private static final String ENTERPRISE_RAW_RESPONSE = "/000000000SKYEE3D/common/in/20/7"; + + private static final Duration OUTBOUND_POLL_INTERVAL = Duration.ofSeconds(2); + private static final Duration INBOUND_POLL_INTERVAL = Duration.ofSeconds(5); + private static final Duration WAIT = computeWait(OUTBOUND_POLL_INTERVAL, INBOUND_POLL_INTERVAL); + + private final String runId = UUID.randomUUID().toString(); + + private Session modemSideSession; + private Session enterpriseSideSession; + + @AfterEach + void cleanup() { + closeQuietly(modemSideSession); + closeQuietly(enterpriseSideSession); + modemSideSession = null; + enterpriseSideSession = null; + } + + @Test + void dropSemanticsOnlyLastEventPerTopicIsObservedModemToEnterprise() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + String modemPublishTopic = modemSensorsTopic(1); + String enterpriseReceiveTopic = enterpriseSatelliteTopic(1); + + SubscriptionContext subscription = enterpriseReceiver.subscribe(enterpriseSideSession, enterpriseReceiveTopic); + + int publishCount = 25; + for (int index = 0; index < publishCount; index++) { + publishText(modemSideSession, modemPublishTopic, "msg-" + index); + } + + boolean received = enterpriseReceiver.await(enterpriseReceiveTopic, WAIT); + Assertions.assertTrue(received, "Expected at least one replicated event within " + WAIT); + + String lastPayload = enterpriseReceiver.getLastText(enterpriseReceiveTopic); + Assertions.assertEquals("msg-" + (publishCount - 1), lastPayload, "Only the last event should be observed"); + + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + + @Test + void tenTopicsProduceTenEventsOnePerTopicModemToEnterprise() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + int topicCount = 10; + SubscriptionContext[] subscriptions = new SubscriptionContext[topicCount]; + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + subscriptions[topicIndex] = enterpriseReceiver.subscribe(enterpriseSideSession, enterpriseSatelliteTopic(topicIndex)); + } + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + String modemPublishTopic = modemSensorsTopic(topicIndex); + + for (int messageIndex = 0; messageIndex < 7; messageIndex++) { + publishText(modemSideSession, modemPublishTopic, "t" + topicIndex + "-m" + messageIndex); + } + } + + boolean allReceived = enterpriseReceiver.awaitAll(topicCount, WAIT); + Assertions.assertTrue(allReceived, "Expected 1 event per topic (" + topicCount + ") within " + WAIT); + + for (int topicIndex = 0; topicIndex < topicCount; topicIndex++) { + String topic = enterpriseSatelliteTopic(topicIndex); + String lastPayload = enterpriseReceiver.getLastText(topic); + Assertions.assertEquals("t" + topicIndex + "-m6", lastPayload, "Last-per-topic must hold for " + topic); + } + + for (SubscriptionContext subscription : subscriptions) { + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + } + + @Test + void rawNanoIoTPacketsAreRoutedUsingIncomingSinMinAndObservedOnOutgoing() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + int sin = 20; + int min = 7; + + String modemPublishTopic = modemRawRequestTopic(sin, min); + String enterpriseReceiveTopic = ENTERPRISE_RAW_RESPONSE; + + SubscriptionContext subscription = enterpriseReceiver.subscribe(enterpriseSideSession, enterpriseReceiveTopic); + + byte[] raw = buildNanoIoTRawPacket(sin, min, "hello-nano"); + publishRaw(modemSideSession, modemPublishTopic, raw); + + boolean received = enterpriseReceiver.await(enterpriseReceiveTopic, WAIT); + Assertions.assertTrue(received, "Expected raw packet arrival within " + WAIT); + + byte[] observed = enterpriseReceiver.getLastBytes(enterpriseReceiveTopic); + Assertions.assertNotNull(observed, "Expected payload bytes"); + Assertions.assertTrue(observed.length >= 2, "Raw payload must be at least 2 bytes"); + Assertions.assertEquals((byte) sin, observed[0], "SIN must be first byte"); + Assertions.assertEquals((byte) min, observed[1], "MIN must be second byte"); + + String tail = new String(observed, 2, observed.length - 2, StandardCharsets.UTF_8); + Assertions.assertEquals("hello-nano", tail, "Raw payload tail must be preserved"); + + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + @Test + void computeNamespaceQueueDepthSixDeliversSixEventsPerTopic() throws Exception { + MultiReceiver enterpriseReceiver = new MultiReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + String modemPublishTopic = modemComputeTopic(0); + String enterpriseReceiveTopic = enterpriseComputeMappedTopic(0); + + int queueDepth = 6; + + SubscriptionContext subscription = enterpriseReceiver.subscribeExpecting(enterpriseSideSession, enterpriseReceiveTopic, queueDepth); + + for (int index = 0; index < queueDepth; index++) { + publishText(modemSideSession, modemPublishTopic, "c-" + index); + } + + boolean receivedSix = enterpriseReceiver.await(enterpriseReceiveTopic, WAIT); + Assertions.assertTrue(receivedSix, "Expected " + queueDepth + " replicated events within " + WAIT); + + Assertions.assertEquals(queueDepth, enterpriseReceiver.getCount(enterpriseReceiveTopic), "Expected exactly " + queueDepth + " events"); + + for (int index = 0; index < queueDepth; index++) { + Assertions.assertEquals("c-" + index, enterpriseReceiver.getText(enterpriseReceiveTopic, index), "Event order mismatch at index " + index); + } + + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + + @Test + void computeNamespaceQueueDepthSixDropsBeyondSixEventsPerTopic() throws Exception { + MultiReceiver enterpriseReceiver = new MultiReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + String modemPublishTopic = modemComputeTopic(1); + String enterpriseReceiveTopic = enterpriseComputeMappedTopic(1); + + int queueDepth = 6; + int publishCount = 10; + + SubscriptionContext subscription = enterpriseReceiver.subscribeExpecting(enterpriseSideSession, enterpriseReceiveTopic, queueDepth); + + for (int index = 0; index < publishCount; index++) { + publishText(modemSideSession, modemPublishTopic, "c-" + index); + } + + boolean receivedSix = enterpriseReceiver.await(enterpriseReceiveTopic, WAIT); + Assertions.assertTrue(receivedSix, "Expected " + queueDepth + " replicated events within " + WAIT); + + int observed = enterpriseReceiver.getCount(enterpriseReceiveTopic); + Assertions.assertEquals(queueDepth, observed, "Expected queue depth cap of " + queueDepth + " events, observed=" + observed); + + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + + + @Test + void statsAnalyticsEmitsAggregatedAdvancedStatsAfter100Events() throws Exception { + TestReceiver enterpriseReceiver = new TestReceiver(); + TestReceiver modemReceiver = new TestReceiver(); + + modemSideSession = createModemSideSession(modemReceiver.listener()); + enterpriseSideSession = createEnterpriseSideSession(enterpriseReceiver.listener()); + + String modemStatsTopic = modemStatsTopic(0); + String enterpriseAggregatedTopic = enterpriseStatsMappedTopic(0); + + SubscriptionContext subscription = enterpriseReceiver.subscribe(enterpriseSideSession, enterpriseAggregatedTopic); + + int eventCount = 100; + for (int index = 0; index < eventCount; index++) { + double value1 = index; + double value2 = index * 2.0; + + JsonObject event = new JsonObject(); + event.addProperty("value1", value1); + event.addProperty("value2", value2); + + publishText(modemSideSession, modemStatsTopic, event.toString()); + } + + boolean received = enterpriseReceiver.await(enterpriseAggregatedTopic, WAIT); + Assertions.assertTrue(received, "Expected aggregated stats event within " + WAIT); + + String payload = enterpriseReceiver.getLastText(enterpriseAggregatedTopic); + + JsonElement root = JsonParser.parseString(payload); + Assertions.assertTrue(root.isJsonObject(), "Aggregated payload must be JSON object: " + payload); + + JsonObject aggregated = root.getAsJsonObject(); + + JsonObject stats1 = getObject(aggregated, "value1", payload); + JsonObject stats2 = getObject(aggregated, "value2", payload); + + assertAdvancedStats(stats1, 0.0, 99.0, 0.0, 99.0, 49.5, 100, 1.0, -1.0, payload); + assertAdvancedStats(stats2, 0.0, 198.0, 0.0, 198.0, 99.0, 100, 2.0, -2.0, payload); + + enterpriseSideSession.removeSubscription(subscription.getKey()); + } + + private static JsonObject getObject(JsonObject root, String name, String payload) { + Assertions.assertTrue(root.has(name), "Missing '" + name + "' in payload: " + payload); + JsonElement element = root.get(name); + Assertions.assertTrue(element.isJsonObject(), "'" + name + "' must be an object. Payload: " + payload); + return element.getAsJsonObject(); + } + + private static void assertAdvancedStats( + JsonObject stats, + double expectedFirst, + double expectedLast, + double expectedMin, + double expectedMax, + double expectedAverage, + int expectedCount, + double expectedSlope, + double expectedIntercept, + String payload + ) { + assertNumberNear(stats, "first", expectedFirst, 0.000001, payload); + assertNumberNear(stats, "last", expectedLast, 0.000001, payload); + assertNumberNear(stats, "min", expectedMin, 0.000001, payload); + assertNumberNear(stats, "max", expectedMax, 0.000001, payload); + assertNumberNear(stats, "average", expectedAverage, 0.000001, payload); + + Assertions.assertTrue(stats.has("count"), "Missing 'count'. Payload: " + payload); + Assertions.assertEquals(expectedCount, stats.get("count").getAsInt(), "count mismatch. Payload: " + payload); + + Assertions.assertTrue(stats.has("mismatched"), "Missing 'mismatched'. Payload: " + payload); + Assertions.assertEquals(0, stats.get("mismatched").getAsInt(), "mismatched must be 0. Payload: " + payload); + + Assertions.assertTrue(stats.has("stdDev"), "Missing 'stdDev' (AdvancedStatistics). Payload: " + payload); + Assertions.assertTrue(stats.get("stdDev").getAsDouble() > 0.0, "stdDev must be > 0. Payload: " + payload); + + assertNumberNear(stats, "slope", expectedSlope, 0.000001, payload); + assertNumberNear(stats, "intercept", expectedIntercept, 0.000001, payload); + + Assertions.assertTrue(stats.has("firstUpdateMillis"), "Missing 'firstUpdateMillis'. Payload: " + payload); + Assertions.assertTrue(stats.has("lastUpdateMillis"), "Missing 'lastUpdateMillis'. Payload: " + payload); + Assertions.assertTrue(stats.get("firstUpdateMillis").getAsLong() > 0L, "firstUpdateMillis must be set. Payload: " + payload); + Assertions.assertTrue(stats.get("lastUpdateMillis").getAsLong() > 0L, "lastUpdateMillis must be set. Payload: " + payload); + } + + private static void assertNumberNear(JsonObject o, String field, double expected, double delta, String payload) { + Assertions.assertTrue(o.has(field), "Missing '" + field + "'. Payload: " + payload); + Assertions.assertEquals(expected, o.get(field).getAsDouble(), delta, "Mismatch for '" + field + "'. Payload: " + payload); + } + + private String modemComputeTopic(int topicIndex) { + return "/compute/" + runId + "/t/" + topicIndex; + } + + private String modemStatsTopic(int topicIndex) { + return "/stats/" + runId + "/t/" + topicIndex; + } + + private String enterpriseComputeMappedTopic(int topicIndex) { + return ENTERPRISE_BASE+"/remote/compute/" + runId + "/t/" + topicIndex; + } + + private static class MultiReceiver { + + private final Map> messagesByTopic; + private final Map latchByTopic; + + private MultiReceiver() { + this.messagesByTopic = new ConcurrentHashMap<>(); + this.latchByTopic = new ConcurrentHashMap<>(); + } + + private MessageListener listener() { + return messageEvent -> { + String destination = messageEvent.getDestinationName(); + Message message = messageEvent.getMessage(); + if (destination != null && message != null) { + messagesByTopic.computeIfAbsent(destination, key -> java.util.Collections.synchronizedList(new java.util.ArrayList<>())) + .add(message); + + CountDownLatch latch = latchByTopic.get(destination); + if (latch != null) { + latch.countDown(); + } + } + + messageEvent.getCompletionTask().run(); + }; + } + + private SubscriptionContext subscribeExpecting(Session session, String topic, int expectedCount) throws Exception { + latchByTopic.put(topic, new CountDownLatch(expectedCount)); + + SubscriptionContextBuilder builder = new SubscriptionContextBuilder(topic, ClientAcknowledgement.AUTO); + SubscriptionContext context = builder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + return context; + } + + private boolean await(String topic, Duration timeout) throws InterruptedException { + CountDownLatch latch = latchByTopic.get(topic); + if (latch == null) { + throw new IllegalStateException("No latch registered for topic " + topic); + } + return latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private int getCount(String topic) { + java.util.List list = messagesByTopic.get(topic); + if (list == null) { + return 0; + } + return list.size(); + } + + private String getText(String topic, int index) { + java.util.List list = messagesByTopic.get(topic); + Assertions.assertNotNull(list, "No messages recorded for topic " + topic); + Assertions.assertTrue(index >= 0 && index < list.size(), "Index out of bounds. topic=" + topic + " index=" + index + " size=" + list.size()); + + Message message = list.get(index); + byte[] bytes = message.getOpaqueData(); + Assertions.assertNotNull(bytes, "Message has null payload for " + topic); + + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private String enterpriseStatsMappedTopic(int topicIndex) { + return ENTERPRISE_BASE+"/remote/stats/" + runId + "/t/" + topicIndex; + } + + + private Session createModemSideSession(MessageListener listener) throws LoginException, IOException { + return createSession("SatelliteModemSide" + System.nanoTime(), 60, 60, false, listener); + } + + private Session createEnterpriseSideSession(MessageListener listener) throws LoginException, IOException { + return createSession("SatelliteEnterpriseSide" + System.nanoTime(), 60, 60, false, listener); + } + + private static void publishText(Session session, String topic, String payload) throws Exception { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(payload, "payload"); + + publishRaw(session, topic, payload.getBytes(StandardCharsets.UTF_8)); + } + + private static void publishRaw(Session session, String topic, byte[] payload) throws Exception { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(topic, "topic"); + Objects.requireNonNull(payload, "payload"); + + MessageBuilder messageBuilder = new MessageBuilder(); + Message message = messageBuilder.setOpaqueData(payload).build(); + + session.findDestination(topic, DestinationType.TOPIC).get().storeMessage(message); + } + + private static byte[] buildNanoIoTRawPacket(int sin, int min, String tailText) { + byte[] tailBytes = tailText.getBytes(StandardCharsets.UTF_8); + ByteBuffer buffer = ByteBuffer.allocate(2 + tailBytes.length); + buffer.put((byte) (sin & 0xFF)); + buffer.put((byte) (min & 0xFF)); + buffer.put(tailBytes); + return buffer.array(); + } + + private String modemSensorsTopic(int topicIndex) { + return MODEM_TELEMETRY_BASE + "/" + runId + "/t/" + topicIndex; + } + + private String enterpriseSatelliteTopic(int topicIndex) { + return ENTERPRISE_TELEMETRY_BASE + "/" + runId + "/t/" + topicIndex; + } + + private String modemRawRequestTopic(int sin, int min) { + return String.format(MODEM_RAW_REQUEST_TEMPLATE, sin, min); + } + + private void closeQuietly(Session session) { + if (session == null) { + return; + } + try { + close(session); + } catch (Exception ignored) { + } + } + + private static Duration computeWait(Duration outboundPoll, Duration inboundPoll) { + Duration slowest = outboundPoll.compareTo(inboundPoll) > 0 ? outboundPoll : inboundPoll; + + long scaledSeconds = slowest.toSeconds() * 8L; + if (scaledSeconds < 30L) { + scaledSeconds = 30L; + } + if (scaledSeconds > 120L) { + scaledSeconds = 120L; + } + return Duration.ofSeconds(scaledSeconds); + } + + private static class TestReceiver { + + private final Map lastByTopic; + private final Map latchByTopic; + + private TestReceiver() { + this.lastByTopic = new ConcurrentHashMap<>(); + this.latchByTopic = new ConcurrentHashMap<>(); + } + + private MessageListener listener() { + return messageEvent -> { + String destination = messageEvent.getDestinationName(); + Message message = messageEvent.getMessage(); + + if (destination != null && message != null) { + lastByTopic.put(destination, message); + + CountDownLatch latch = latchByTopic.get(destination); + if (latch != null) { + latch.countDown(); + } + } + + messageEvent.getCompletionTask().run(); + }; + } + + private SubscriptionContext subscribe(Session session, String topic) throws Exception { + latchByTopic.put(topic, new CountDownLatch(1)); + + SubscriptionContextBuilder builder = new SubscriptionContextBuilder(topic, ClientAcknowledgement.AUTO); + SubscriptionContext context = builder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + return context; + } + + private boolean await(String topic, Duration timeout) throws InterruptedException { + CountDownLatch latch = latchByTopic.get(topic); + if (latch == null) { + throw new IllegalStateException("No latch registered for topic " + topic); + } + return latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private boolean awaitAll(int expectedTopics, Duration timeout) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + for (CountDownLatch latch : latchByTopic.values()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return false; + } + if (!latch.await(remaining, TimeUnit.NANOSECONDS)) { + return false; + } + } + return lastByTopic.size() >= expectedTopics; + } + + private String getLastText(String topic) { + Message message = lastByTopic.get(topic); + Assertions.assertNotNull(message, "No message recorded for topic " + topic); + + byte[] bytes = message.getOpaqueData(); + Assertions.assertNotNull(bytes, "Message has null payload for " + topic); + + return new String(bytes, StandardCharsets.UTF_8); + } + + private byte[] getLastBytes(String topic) { + Message message = lastByTopic.get(topic); + Assertions.assertNotNull(message, "No message recorded for topic " + topic); + + byte[] bytes = message.getOpaqueData(); + Assertions.assertNotNull(bytes, "Message has null payload for " + topic); + + return bytes; + } + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteGatewayHardeningTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteGatewayHardeningTest.java new file mode 100644 index 000000000..6a7728910 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteGatewayHardeningTest.java @@ -0,0 +1,138 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.satellite.protocol; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class SatelliteGatewayHardeningTest { + + @Test + void returnsNullForInvalidTotalPacketsZero() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + SatelliteMessage fragment = fragment((byte) 1, 0, 0, false, (byte) 1, bytes(1, 2, 3)); + + SatelliteMessage rebuilt = rebuilder.rebuild(fragment); + assertNull(rebuilt, "totalPackets=0 must be rejected"); + } + + @Test + void returnsNullForPacketNumberEqualToTotalPackets() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + SatelliteMessage fragment = fragment((byte) 2, 2, 2, false, (byte) 1, bytes(1)); + + SatelliteMessage rebuilt = rebuilder.rebuild(fragment); + assertNull(rebuilt, "packetNumber==totalPackets must be rejected"); + } + + @Test + void returnsNullForNegativePacketNumber() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + SatelliteMessage fragment = fragment((byte) 3, -1, 2, false, (byte) 1, bytes(1)); + + SatelliteMessage rebuilt = rebuilder.rebuild(fragment); + assertNull(rebuilt, "negative packetNumber must be rejected"); + } + + @Test + void doesNotBreakOtherStreamsWhenGarbageArrives() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + // Valid stream A: 2 fragments + SatelliteMessage a0 = fragment((byte) 10, 0, 2, false, (byte) 1, bytes(10)); + SatelliteMessage a1 = fragment((byte) 10, 1, 2, false, (byte) 1, bytes(11)); + + // Garbage: bad packetNumber + SatelliteMessage bad = fragment((byte) 11, 999, 2, false, (byte) 1, bytes(99)); + + assertNull(rebuilder.rebuild(a0)); + assertNull(rebuilder.rebuild(bad)); + + SatelliteMessage rebuilt = rebuilder.rebuild(a1); + assertNotNull(rebuilt, "valid stream must rebuild even with interleaved garbage"); + assertEquals(10, rebuilt.getStreamNumber()); + } + + @Test + void rejectsStreamCollision_totalPacketsMismatch() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + byte stream = 20; + + SatelliteMessage a0 = fragment(stream, 0, 2, false, (byte) 1, bytes(1)); + SatelliteMessage b0 = fragment(stream, 0, 3, false, (byte) 1, bytes(2)); // mismatch totalPackets + SatelliteMessage a1 = fragment(stream, 1, 2, false, (byte) 1, bytes(3)); + + assertNull(rebuilder.rebuild(a0)); + assertNull(rebuilder.rebuild(b0), "collision must be rejected and state cleared"); + + // After collision, even providing remaining valid fragments must not rebuild the old message. + assertNull(rebuilder.rebuild(a1), "stream state should have been cleared on mismatch"); + } + + // ---------------------------- + // Wiring: your API is rebuild() returning null if incomplete/invalid. + // ---------------------------- + + private static Optional offer(SatelliteMessageRebuilder rebuilder, SatelliteMessage fragment) { + return Optional.ofNullable(rebuilder.rebuild(fragment)); + } + + // ---------------------------- + // Helpers: build a fragment with the fields your rebuilder uses. + // This assumes SatelliteMessage has setters used elsewhere in your tests. + // If your SatelliteMessage is immutable, replace with the right constructor/builder. + // ---------------------------- + + private static SatelliteMessage fragment( + byte streamNumber, + int packetNumber, + int totalPackets, + boolean compressed, + byte transformationId, + byte[] payload + ) { + SatelliteMessage message = new SatelliteMessage(); + message.setStreamNumber(streamNumber); + message.setPacketNumber(packetNumber); + message.setTotalPackets(totalPackets); + message.setCompressed(compressed); + message.setTransformationId(transformationId); + + // The current factory uses getMessage() to concatenate. + message.setMessage(payload); + + return message; + } + + private static byte[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteMessageRebuilderTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteMessageRebuilderTest.java new file mode 100644 index 000000000..f7cf3906d --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/satellite/protocol/SatelliteMessageRebuilderTest.java @@ -0,0 +1,87 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.protocol.impl.satellite.protocol; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SatelliteMessageRebuilderTest { + + @Test + void rebuildIsCorrectRegardlessOfArrivalOrder() { + SatelliteMessageRebuilder rebuilder = new SatelliteMessageRebuilder(); + + byte transformerId = 0; + boolean compressed = false; + + byte[] original = buildDeterministicPayload(4096); + + int maxBufferSize = 64; + List fragments = + SatelliteMessageFactory.createMessages(original, maxBufferSize, compressed, transformerId); + + assertNotNull(fragments); + assertTrue(fragments.size() > 1, "Test must create multiple fragments."); + + // Feed in reverse order (worst-case). + List reverse = new ArrayList<>(fragments); + Collections.reverse(reverse); + + SatelliteMessage rebuilt = feedAll(rebuilder, reverse); + + assertNotNull(rebuilt, "Rebuilder did not return a completed message."); + assertArrayEquals(original, rebuilt.getMessage(), "Rebuilt payload differs from original."); + + // Also test a rotated order (another realistic out-of-order delivery pattern). + rebuilder.clear(); + + List rotated = new ArrayList<>(fragments); + Collections.rotate(rotated, fragments.size() / 2); + + SatelliteMessage rebuilt2 = feedAll(rebuilder, rotated); + + assertNotNull(rebuilt2, "Rebuilder did not return a completed message (rotated case)."); + assertArrayEquals(original, rebuilt2.getMessage(), "Rebuilt payload differs from original (rotated case)."); + } + + private static SatelliteMessage feedAll(SatelliteMessageRebuilder rebuilder, List arrivalOrder) { + SatelliteMessage rebuilt = null; + for (SatelliteMessage fragment : arrivalOrder) { + SatelliteMessage maybe = rebuilder.rebuild(fragment); + if (maybe != null) { + rebuilt = maybe; + } + } + return rebuilt; + } + + private static byte[] buildDeterministicPayload(int size) { + byte[] data = new byte[size]; + for (int i = 0; i < size; i++) { + data[i] = (byte) (i * 31 + 7); + } + return data; + } +} diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechDownlinkViaUdpTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechDownlinkViaUdpTest.java new file mode 100644 index 000000000..f8257dc08 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechDownlinkViaUdpTest.java @@ -0,0 +1,405 @@ +package io.mapsmessaging.network.protocol.impl.semtech; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +class SemtechDownlinkViaUdpTest extends BaseTestConfig { + + private static final String UDP_HOST = "127.0.0.1"; + private static final int UDP_PORT = 1700; + + private static final String OUTBOUND_ROOT = "/semtech/outbound"; + + private static final int VERSION = 0x02; + + private static final int PULL_DATA = 0x02; + private static final int PULL_ACK = 0x04; + + private static final int PULL_RESP = 0x03; + + private static final int TX_ACK = 0x05; + + @Test + void testDownlinkRegisteredGatewayReceivesPullRespAndTxAckIsAccepted() + throws LoginException, IOException, InterruptedException, ExecutionException, TimeoutException { + + String gatewayId = "0102030405060708"; + byte[] gatewayEui = hexToBytes(gatewayId); + + try (DatagramSocket gatewaySocket = new DatagramSocket()) { + gatewaySocket.setSoTimeout(3000); + + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + JsonObject expectedTxpk = buildTxpk(); + JsonObject pullRespJson = new JsonObject(); + pullRespJson.add("txpk", expectedTxpk); + + publishDownlinkRequestToMaps(gatewayId, pullRespJson.toString()); + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + byte[] pullRespFrame = receiveUdp(gatewaySocket, 4096); + + SemtechFrame parsed = parseSemtechFrame(pullRespFrame); + Assertions.assertEquals(PULL_RESP, parsed.identifier, "Expected PULL_RESP (0x03)"); + + JsonObject actualJson = parseJsonObject(new String(parsed.jsonBytes, StandardCharsets.UTF_8)); + + Assertions.assertTrue(actualJson.has("txpk"), "PULL_RESP JSON must contain txpk. JSON: " + actualJson); + Assertions.assertTrue(actualJson.get("txpk").isJsonObject(), "txpk must be an object. JSON: " + actualJson); + + JsonObject actualTxpk = actualJson.getAsJsonObject("txpk"); + assertTxpkEquals(expectedTxpk, actualTxpk); + + JsonObject txAckJson = new JsonObject(); + JsonObject txpkAck = new JsonObject(); + txpkAck.addProperty("error", "NONE"); + txAckJson.add("txpk_ack", txpkAck); + + InetAddress address = InetAddress.getByName(UDP_HOST); + byte[] txAckFrame = buildTxAckFrame(parsed.token, gatewayEui, txAckJson.toString().getBytes(StandardCharsets.UTF_8)); + DatagramPacket txAckPacket = new DatagramPacket(txAckFrame, txAckFrame.length, address, UDP_PORT); + gatewaySocket.send(txAckPacket); + } + } + + @Test + void testDownlinkUnregisteredGatewayDoesNotSendAnyUdp() + throws LoginException, IOException, InterruptedException, ExecutionException, TimeoutException { + + String registeredGatewayId = "0102030405060708"; + byte[] registeredGatewayEui = hexToBytes(registeredGatewayId); + + String unregisteredGatewayId = "1112131415161718"; + + try (DatagramSocket gatewaySocket = new DatagramSocket()) { + gatewaySocket.setSoTimeout(1500); + + registerGatewayWithPullData(gatewaySocket, registeredGatewayEui); + + JsonObject expectedTxpk = buildTxpk(); + JsonObject pullRespJson = new JsonObject(); + pullRespJson.add("txpk", expectedTxpk); + + publishDownlinkRequestToMaps(unregisteredGatewayId, pullRespJson.toString()); + + Assertions.assertThrows(SocketTimeoutException.class, () -> receiveUdp(gatewaySocket, 4096), + "Expected no UDP downlink for unregistered gatewayId=" + unregisteredGatewayId); + } + } + + @Test + void testDownlinkOpaqueBytesAreEncodedToTxpkUsingDefaults() + throws LoginException, IOException, InterruptedException, ExecutionException, TimeoutException { + + String gatewayId = "0102030405060708"; + byte[] gatewayEui = hexToBytes(gatewayId); + + byte[] opaquePayload = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + + try (DatagramSocket gatewaySocket = new DatagramSocket()) { + gatewaySocket.setSoTimeout(3000); + + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + publishDownlinkRequestToMaps(gatewayId, opaquePayload); + + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + byte[] pullRespFrame = receiveUdp(gatewaySocket, 4096); + SemtechFrame parsed = parseSemtechFrame(pullRespFrame); + Assertions.assertEquals(PULL_RESP, parsed.identifier, "Expected PULL_RESP (0x03)"); + + JsonObject actualJson = parseJsonObject(new String(parsed.jsonBytes, StandardCharsets.UTF_8)); + Assertions.assertTrue(actualJson.has("txpk"), "PULL_RESP JSON must contain txpk. JSON: " + actualJson); + + JsonObject txpk = actualJson.getAsJsonObject("txpk"); + + // Verify defaults exist (these must match SemtechConfig tx.* defaults) + assertJsonBoolean(txpk, "imme", true); + assertJsonDouble(txpk, "freq", 866.349812, 0.000001); + assertJsonLong(txpk, "rfch", 0); + assertJsonLong(txpk, "powe", 14); + assertJsonString(txpk, "modu", "LORA"); + assertJsonString(txpk, "datr", "SF7BW125"); + assertJsonString(txpk, "codr", "4/5"); + assertJsonBoolean(txpk, "ipol", true); + + // Verify base64 + size computed from opaque bytes + String expectedBase64 = Base64.getEncoder().encodeToString(opaquePayload); + assertJsonString(txpk, "data", expectedBase64); + assertJsonLong(txpk, "size", opaquePayload.length); + + // Verify decoding matches original bytes + byte[] decoded = Base64.getDecoder().decode(txpk.get("data").getAsString()); + Assertions.assertArrayEquals(opaquePayload, decoded, "Decoded txpk.data must match original opaque payload"); + } + } + + @Test + void testDownlinkNonSemtechJsonIsEncodedToTxpkUsingDefaults() + throws LoginException, IOException, InterruptedException, ExecutionException, TimeoutException { + + String gatewayId = "0102030405060708"; + byte[] gatewayEui = hexToBytes(gatewayId); + + JsonObject nonSemtechJson = new JsonObject(); + nonSemtechJson.addProperty("hello", "world"); + nonSemtechJson.addProperty("n", 123); + + byte[] jsonBytes = nonSemtechJson.toString().getBytes(StandardCharsets.UTF_8); + + try (DatagramSocket gatewaySocket = new DatagramSocket()) { + gatewaySocket.setSoTimeout(3000); + + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + publishDownlinkRequestToMaps(gatewayId, nonSemtechJson.toString()); + + registerGatewayWithPullData(gatewaySocket, gatewayEui); + + byte[] pullRespFrame = receiveUdp(gatewaySocket, 4096); + SemtechFrame parsed = parseSemtechFrame(pullRespFrame); + Assertions.assertEquals(PULL_RESP, parsed.identifier, "Expected PULL_RESP (0x03)"); + + JsonObject actualJson = parseJsonObject(new String(parsed.jsonBytes, StandardCharsets.UTF_8)); + Assertions.assertTrue(actualJson.has("txpk"), "PULL_RESP JSON must contain txpk. JSON: " + actualJson); + + JsonObject txpk = actualJson.getAsJsonObject("txpk"); + + // Defaults + assertJsonBoolean(txpk, "imme", true); + assertJsonDouble(txpk, "freq", 866.349812, 0.000001); + assertJsonLong(txpk, "rfch", 0); + assertJsonLong(txpk, "powe", 14); + assertJsonString(txpk, "modu", "LORA"); + assertJsonString(txpk, "datr", "SF7BW125"); + assertJsonString(txpk, "codr", "4/5"); + assertJsonBoolean(txpk, "ipol", true); + + // Encoded payload must be the literal JSON bytes (not parsed/rewritten) + String expectedBase64 = Base64.getEncoder().encodeToString(jsonBytes); + assertJsonString(txpk, "data", expectedBase64); + assertJsonLong(txpk, "size", jsonBytes.length); + + byte[] decoded = Base64.getDecoder().decode(txpk.get("data").getAsString()); + Assertions.assertArrayEquals(jsonBytes, decoded, "Decoded txpk.data must match original JSON bytes"); + } + } + + private void registerGatewayWithPullData(DatagramSocket gatewaySocket, byte[] gatewayEui) throws IOException { + int pullToken = ThreadLocalRandom.current().nextInt(0, 0x10000); + byte[] pullDataFrame = buildPullDataFrame(pullToken, gatewayEui); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket pullDataPacket = new DatagramPacket(pullDataFrame, pullDataFrame.length, address, UDP_PORT); + gatewaySocket.send(pullDataPacket); + + byte[] pullAck = receiveUdp(gatewaySocket, 64); + assertHeader(pullAck, PULL_ACK, pullToken); + } + + private void publishDownlinkRequestToMaps(String gatewayId, String jsonPayload) + throws LoginException, IOException, ExecutionException, InterruptedException, TimeoutException { + + publishDownlinkRequestToMaps(gatewayId, jsonPayload.getBytes(StandardCharsets.UTF_8)); + } + + private void publishDownlinkRequestToMaps(String gatewayId, byte[] payloadBytes) + throws LoginException, IOException, ExecutionException, InterruptedException, TimeoutException { + + String topic = OUTBOUND_ROOT + "/" + gatewayId; + + Session session = createSession("semtechDownlinkPublisher" + System.nanoTime(), 60, 60, false, null); + Assertions.assertNotNull(session); + + try { + MessageBuilder messageBuilder = new MessageBuilder(); + messageBuilder.setOpaqueData(payloadBytes); + Message message = messageBuilder.build(); + + session.findDestination(topic, DestinationType.TOPIC) + .thenApply(destination -> { + try { + destination.storeMessage(message); + } catch (IOException e) { + throw new RuntimeException(e); + } + return destination; + }) + .get(1, TimeUnit.SECONDS); + + } finally { + close(session); + } + } + + private JsonObject buildTxpk() { + JsonObject txpk = new JsonObject(); + txpk.addProperty("imme", true); + txpk.addProperty("freq", 866.349812); + txpk.addProperty("rfch", 0); + txpk.addProperty("powe", 14); + txpk.addProperty("modu", "LORA"); + txpk.addProperty("datr", "SF7BW125"); + txpk.addProperty("codr", "4/5"); + txpk.addProperty("ipol", true); + txpk.addProperty("size", 4); + txpk.addProperty("data", "AQIDBA=="); + return txpk; + } + + private void assertTxpkEquals(JsonObject expected, JsonObject actual) { + assertJsonBoolean(actual, "imme", expected.get("imme").getAsBoolean()); + assertJsonDouble(actual, "freq", expected.get("freq").getAsDouble(), 0.000001); + assertJsonLong(actual, "rfch", expected.get("rfch").getAsLong()); + assertJsonLong(actual, "powe", expected.get("powe").getAsLong()); + assertJsonString(actual, "modu", expected.get("modu").getAsString()); + assertJsonString(actual, "datr", expected.get("datr").getAsString()); + assertJsonString(actual, "codr", expected.get("codr").getAsString()); + assertJsonBoolean(actual, "ipol", expected.get("ipol").getAsBoolean()); + assertJsonLong(actual, "size", expected.get("size").getAsLong()); + assertJsonString(actual, "data", expected.get("data").getAsString()); + } + + private byte[] buildPullDataFrame(int token, byte[] gatewayEui) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] frame = new byte[4 + 8]; + frame[0] = (byte) (VERSION & 0xFF); + frame[1] = (byte) ((token >> 8) & 0xFF); + frame[2] = (byte) (token & 0xFF); + frame[3] = (byte) (PULL_DATA & 0xFF); + System.arraycopy(gatewayEui, 0, frame, 4, 8); + return frame; + } + + private byte[] buildTxAckFrame(int token, byte[] gatewayEui, byte[] jsonBytes) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] frame = new byte[4 + 8 + jsonBytes.length]; + frame[0] = (byte) (VERSION & 0xFF); + frame[1] = (byte) ((token >> 8) & 0xFF); + frame[2] = (byte) (token & 0xFF); + frame[3] = (byte) (TX_ACK & 0xFF); + System.arraycopy(gatewayEui, 0, frame, 4, 8); + System.arraycopy(jsonBytes, 0, frame, 12, jsonBytes.length); + return frame; + } + + private byte[] receiveUdp(DatagramSocket socket, int maxBytes) throws IOException { + byte[] buffer = new byte[maxBytes]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + socket.receive(packet); + + byte[] data = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), data, 0, packet.getLength()); + return data; + } + + private void assertHeader(byte[] frame, int expectedIdentifier, int expectedToken) { + SemtechFrame parsed = parseSemtechFrame(frame); + Assertions.assertEquals(expectedIdentifier, parsed.identifier, "Identifier mismatch"); + Assertions.assertEquals(expectedToken, parsed.token, "Token mismatch"); + } + + private SemtechFrame parseSemtechFrame(byte[] frame) { + Assertions.assertNotNull(frame); + Assertions.assertTrue(frame.length >= 4, "Frame must be at least 4 bytes"); + + int version = frame[0] & 0xFF; + Assertions.assertEquals(VERSION, version, "Semtech version mismatch"); + + int token = ((frame[1] & 0xFF) << 8) | (frame[2] & 0xFF); + int identifier = frame[3] & 0xFF; + + byte[] jsonBytes = new byte[Math.max(0, frame.length - 4)]; + if (jsonBytes.length > 0) { + System.arraycopy(frame, 4, jsonBytes, 0, jsonBytes.length); + } + + SemtechFrame parsed = new SemtechFrame(); + parsed.token = token; + parsed.identifier = identifier; + parsed.jsonBytes = jsonBytes; + return parsed; + } + + private JsonObject parseJsonObject(String json) { + try { + JsonElement element = JsonParser.parseString(json); + Assertions.assertTrue(element.isJsonObject(), "Expected JSON object. JSON: " + json); + return element.getAsJsonObject(); + } catch (Exception e) { + Assertions.fail("Invalid JSON received: " + json, e); + return null; + } + } + + private void assertJsonString(JsonObject object, String field, String expected) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + Assertions.assertEquals(expected, object.get(field).getAsString(), "Field '" + field + "' mismatch. Object: " + object); + } + + private void assertJsonLong(JsonObject object, String field, long expected) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + Assertions.assertEquals(expected, object.get(field).getAsLong(), "Field '" + field + "' mismatch. Object: " + object); + } + + private void assertJsonDouble(JsonObject object, String field, double expected, double delta) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + double actual = object.get(field).getAsDouble(); + Assertions.assertTrue(Math.abs(expected - actual) <= delta, + "Field '" + field + "' mismatch expected=" + expected + " actual=" + actual + " Object: " + object); + } + + private void assertJsonBoolean(JsonObject object, String field, boolean expected) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + Assertions.assertEquals(expected, object.get(field).getAsBoolean(), "Field '" + field + "' mismatch. Object: " + object); + } + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + Assertions.assertEquals(0, length % 2, "hex string length must be even"); + + byte[] bytes = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + int value = Integer.parseInt(hex.substring(i, i + 2), 16); + bytes[i / 2] = (byte) value; + } + return bytes; + } + + private static class SemtechFrame { + private int token; + private int identifier; + private byte[] jsonBytes; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpRigidTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpRigidTest.java new file mode 100644 index 000000000..5a763fc13 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpRigidTest.java @@ -0,0 +1,759 @@ +package io.mapsmessaging.network.protocol.impl.semtech; + +import com.google.gson.*; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.network.protocol.impl.semtech.json.PushDataJSON; +import io.mapsmessaging.network.protocol.impl.semtech.json.ReceivePacket; +import io.mapsmessaging.network.protocol.impl.semtech.json.StatPacket; +import io.mapsmessaging.network.protocol.impl.semtech.json.TxPackAck; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class SemtechViaUdpRigidTest extends BaseTestConfig { + + private static final String SUBSCRIBE_TOPIC = "/semtech/#"; + + private static final String UDP_HOST = "127.0.0.1"; + private static final int UDP_PORT = 1700; + + private static final int VERSION = 0x02; + + private static final int PUSH_DATA = 0x00; + private static final int PUSH_ACK = 0x01; + + private static final int PULL_DATA = 0x02; + private static final int PULL_ACK = 0x04; + + private static final int TX_ACK = 0x05; + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + @Test + void testPushDataLoRaStrictRoundTrip() throws LoginException, IOException, InterruptedException { + PushDataJSON expected = new PushDataJSON(); + expected.setRxpk(new ReceivePacket[] { + buildExpectedReceivePacketLoRa( + "2013-03-31T16:21:17.528002Z", + 3512348611L, + 866.349812, + 2, + 0, + 1, + "LORA", + "SF7BW125", + "4/6", + -35, + 5.1, + 15, + "VEVTVF9QQUNLRVRfMTIzNA==" + ) + }); + expected.setStat(null); + + PushDataJSON received = sendPushDataAndGetPublished(expected, buildIncomingSemtechLoRaJson()); + assertRxpkCount(received, 1); + + ReceivePacket rx = received.getRxpk()[0]; + assertReceivePacketLoRaEquals(expected.getRxpk()[0], rx); + } + + @Test + void testPushDataMultiRxpkStrict() throws LoginException, IOException, InterruptedException { + ReceivePacket[] expectedRxpk = new ReceivePacket[] { + buildExpectedReceivePacketLoRa( + "2013-03-31T16:21:17.528002Z", + 1111111111L, + 866.349812, + 2, + 0, + 1, + "LORA", + "SF7BW125", + "4/6", + -35, + 5.1, + 4, + "AQIDBA==" + ), + buildExpectedReceivePacketLoRa( + "2013-03-31T16:21:18.528002Z", + 2222222222L, + 867.125000, + 3, + 1, + 1, + "LORA", + "SF12BW125", + "4/5", + -80, + -1.25, + 5, + "AQIDBAU=" + ) + }; + + Message publishedMessage = sendPushDataAndGetPublishedMessage(buildIncomingSemtechMultiLoRaJson()); + JsonObject publishedJson = parsePublishedAsJsonObject(publishedMessage); + + assertRawSemtechRxpkEquals(publishedJson, expectedRxpk); + } + private Message sendPushDataAndGetPublishedMessage(JsonObject incomingSemtechJson) + throws LoginException, IOException, InterruptedException { + + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch firstMessageLatch = new CountDownLatch(1); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + messages.add(messageEvent.getMessage()); + firstMessageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("semtechUdpRigidTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = + new SubscriptionContextBuilder(SUBSCRIBE_TOPIC, ClientAcknowledgement.AUTO); + + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + + byte[] gatewayEui = hexToBytes("0102030405060708"); + int token = ThreadLocalRandom.current().nextInt(0, 0x10000); + + byte[] pushPacket = buildPushDataPacket( + token, + gatewayEui, + incomingSemtechJson.toString().getBytes(StandardCharsets.UTF_8) + ); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(2000); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket packet = new DatagramPacket(pushPacket, pushPacket.length, address, UDP_PORT); + socket.send(packet); + + byte[] ackBytes = receiveUdp(socket, 64); + validatePushAck(ackBytes, token); + } + + boolean received = firstMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No Semtech messages were published after UDP injection to port " + UDP_PORT); + Assertions.assertTrue(receivedCount.get() > 0, "Expected at least one message after UDP injection"); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (messages.isEmpty() && attempts < 20); + + session.removeSubscription(context.getKey()); + + Assertions.assertEquals(1, messages.size(), "Expected exactly one published message for one PUSH_DATA"); + return messages.getFirst(); + } finally { + close(session); + } + } + + private JsonObject parsePublishedAsJsonObject(Message message) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + try { + JsonObject object = JsonParser.parseString(payload).getAsJsonObject(); + Assertions.assertTrue(object.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + return object; + } catch (Exception e) { + Assertions.fail("Published payload is not valid JSON object. Payload: " + payload, e); + return null; + } + } + + private void assertRawSemtechRxpkEquals(JsonObject published, ReceivePacket[] expected) { + Assertions.assertNotNull(published, "Published JSON must not be null"); + Assertions.assertTrue(published.has("rxpk"), "Missing field 'rxpk'. Object: " + published); + Assertions.assertTrue(published.get("rxpk").isJsonArray(), "rxpk must be an array. Object: " + published); + + JsonArray rxpk = published.getAsJsonArray("rxpk"); + Assertions.assertEquals(expected.length, rxpk.size(), "rxpk count mismatch. Object: " + published); + + for (int i = 0; i < expected.length; i++) { + JsonObject actual = rxpk.get(i).getAsJsonObject(); + ReceivePacket exp = expected[i]; + + assertJsonString(actual, "time", exp.getTime()); + assertJsonLong(actual, "tmst", exp.getTmst()); + assertJsonLong(actual, "chan", exp.getChan()); + assertJsonLong(actual, "rfch", exp.getRfch()); + assertJsonLong(actual, "stat", exp.getStat()); + assertJsonString(actual, "modu", exp.getModu()); + assertJsonString(actual, "codr", exp.getCodr()); + assertJsonLong(actual, "rssi", exp.getRssi()); + assertJsonLong(actual, "size", exp.getSize()); + assertJsonString(actual, "data", exp.getData()); + + assertJsonDouble(actual, "freq", exp.getFreq(), 0.000001); + assertJsonDouble(actual, "lsnr", exp.getLsnr(), 0.000001); + + assertJsonString(actual, "datr", exp.getDatr()); + } + } + + private void assertJsonString(JsonObject object, String field, String expected) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + Assertions.assertEquals(expected, object.get(field).getAsString(), "Field '" + field + "' mismatch. Object: " + object); + } + + private void assertJsonLong(JsonObject object, String field, long expected) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + Assertions.assertEquals(expected, object.get(field).getAsLong(), "Field '" + field + "' mismatch. Object: " + object); + } + + private void assertJsonDouble(JsonObject object, String field, double expected, double delta) { + Assertions.assertTrue(object.has(field), "Missing field '" + field + "'. Object: " + object); + Assertions.assertFalse(object.get(field).isJsonNull(), "Field '" + field + "' must not be null. Object: " + object); + double actual = object.get(field).getAsDouble(); + Assertions.assertTrue(Math.abs(expected - actual) <= delta, + "Field '" + field + "' mismatch. expected=" + expected + " actual=" + actual + " Object: " + object); + } + + @Test + void testPushDataFskNumericDatrStrict() throws LoginException, IOException, InterruptedException { + PushDataJSON expected = new PushDataJSON(); + ReceivePacket fsk = new ReceivePacket(); + fsk.setTime("2013-03-31T16:21:17.528002Z"); + fsk.setTmst(3333333333L); + fsk.setFreq(868.300000); + fsk.setChan(0); + fsk.setRfch(0); + fsk.setStat(1); + fsk.setModu("FSK"); + fsk.setDatr(""+50000L); // numeric bitrate + fsk.setCodr(null); + fsk.setRssi(-42); + fsk.setLsnr(0.0); + fsk.setSize(3); + fsk.setData("AQID"); + + expected.setRxpk(new ReceivePacket[] { fsk }); + expected.setStat(null); + + PushDataJSON received = sendPushDataAndGetPublished(expected, buildIncomingSemtechFskJson()); + assertRxpkCount(received, 1); + + ReceivePacket rx = received.getRxpk()[0]; + Assertions.assertEquals("FSK", rx.getModu(), "modu mismatch"); + Assertions.assertEquals("50000", rx.getDatr(), "FSK datr numeric mismatch"); + Assertions.assertEquals(3L, rx.getSize(), "size mismatch"); + Assertions.assertEquals("AQID", rx.getData(), "data mismatch"); + assertDoubleEquals(868.300000, rx.getFreq(), 0.000001, "freq mismatch"); + } + + @Test + void testPushDataCrcFailPreserved() throws LoginException, IOException, InterruptedException { + PushDataJSON expected = new PushDataJSON(); + expected.setRxpk(new ReceivePacket[] { + buildExpectedReceivePacketLoRa( + "2013-03-31T16:21:17.528002Z", + 4444444444L, + 866.900000, + 1, + 0, + -1, // CRC fail + "LORA", + "SF9BW125", + "4/7", + -90, + 2.0, + 2, + "AAE=" + ) + }); + + PushDataJSON received = sendPushDataAndGetPublished(expected, buildIncomingSemtechCrcFailJson()); + assertRxpkCount(received, 1); + + ReceivePacket rx = received.getRxpk()[0]; + Assertions.assertEquals(-1L, rx.getStat(), "Expected stat=-1 to be preserved for CRC fail rxpk"); + Assertions.assertEquals("AAE=", rx.getData(), "data mismatch"); + Assertions.assertEquals(2L, rx.getSize(), "size mismatch"); + } + + @Test + void testPullDataAckTokenMatches() throws IOException { + int token = ThreadLocalRandom.current().nextInt(0, 0x10000); + byte[] gatewayEui = hexToBytes("0102030405060708"); + + byte[] pullData = buildPullDataPacket(token, gatewayEui); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(2000); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket packet = new DatagramPacket(pullData, pullData.length, address, UDP_PORT); + socket.send(packet); + + byte[] ackBytes = receiveUdp(socket, 64); + validatePullAck(ackBytes, token); + } + } + + @Test + void testTxAckAccepted() throws IOException { + int token = ThreadLocalRandom.current().nextInt(0, 0x10000); + byte[] gatewayEui = hexToBytes("0102030405060708"); + + JsonObject txAckJson = new JsonObject(); + JsonObject inner = new JsonObject(); + inner.addProperty("error", "NONE"); + txAckJson.add("txpk_ack", inner); + + byte[] txAck = buildTxAckPacket(token, gatewayEui, txAckJson.toString().getBytes(StandardCharsets.UTF_8)); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(1500); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket packet = new DatagramPacket(txAck, txAck.length, address, UDP_PORT); + socket.send(packet); + + // Protocol does not require an ACK for TX_ACK. + // This test is simply "server doesn't throw / doesn't die / doesn't spam errors". + // If you publish TX_ACK internally, wire in a subscription later and assert it. + } + } + + // ----------------------------- + // Core: send PUSH_DATA + capture published + // ----------------------------- + + private PushDataJSON sendPushDataAndGetPublished(PushDataJSON expectedPublished, JsonObject incomingSemtechJson) + throws LoginException, IOException, InterruptedException { + + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch firstMessageLatch = new CountDownLatch(1); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + messages.add(messageEvent.getMessage()); + firstMessageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("semtechUdpRigidTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = + new SubscriptionContextBuilder(SUBSCRIBE_TOPIC, ClientAcknowledgement.AUTO); + + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + + byte[] gatewayEui = hexToBytes("0102030405060708"); + int token = ThreadLocalRandom.current().nextInt(0, 0x10000); + + byte[] pushPacket = buildPushDataPacket(token, gatewayEui, incomingSemtechJson.toString().getBytes(StandardCharsets.UTF_8)); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(2000); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket packet = new DatagramPacket(pushPacket, pushPacket.length, address, UDP_PORT); + socket.send(packet); + + byte[] ackBytes = receiveUdp(socket, 64); + validatePushAck(ackBytes, token); + } + + boolean received = firstMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No Semtech messages were published after UDP injection to port " + UDP_PORT); + Assertions.assertTrue(receivedCount.get() > 0, "Expected at least one message after UDP injection"); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (messages.isEmpty() && attempts < 20); + + session.removeSubscription(context.getKey()); + + Assertions.assertEquals(1, messages.size(), "Expected exactly one published message for one PUSH_DATA"); + + PushDataJSON parsed = parsePublishedPushData(messages.getFirst()); + Assertions.assertNotNull(parsed, "Parsed PushDataJSON must not be null"); + + // Basic strictness: presence of expected major section(s) + if (expectedPublished.getRxpk() != null && expectedPublished.getRxpk().length > 0) { + Assertions.assertNotNull(parsed.getRxpk(), "Expected rxpk to be present"); + } + if (expectedPublished.getStat() != null) { + Assertions.assertNotNull(parsed.getStat(), "Expected stats to be present"); + } + + return parsed; + } finally { + close(session); + } + } + + private PushDataJSON parsePublishedPushData(Message message) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + try { + return GSON.fromJson(payload, PushDataJSON.class); + } catch (Exception e) { + Assertions.fail("Published payload is not compatible with PushDataJSON. Payload: " + payload, e); + return null; + } + } + + // ----------------------------- + // Packet builders + // ----------------------------- + + private byte[] buildPushDataPacket(int token, byte[] gatewayEui, byte[] jsonBytes) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] packet = new byte[4 + 8 + jsonBytes.length]; + + packet[0] = (byte) (VERSION & 0xFF); + packet[1] = (byte) ((token >> 8) & 0xFF); + packet[2] = (byte) (token & 0xFF); + packet[3] = (byte) (PUSH_DATA & 0xFF); + + System.arraycopy(gatewayEui, 0, packet, 4, 8); + System.arraycopy(jsonBytes, 0, packet, 12, jsonBytes.length); + + return packet; + } + + private byte[] buildPullDataPacket(int token, byte[] gatewayEui) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] packet = new byte[4 + 8]; + packet[0] = (byte) (VERSION & 0xFF); + packet[1] = (byte) ((token >> 8) & 0xFF); + packet[2] = (byte) (token & 0xFF); + packet[3] = (byte) (PULL_DATA & 0xFF); + System.arraycopy(gatewayEui, 0, packet, 4, 8); + + return packet; + } + + private byte[] buildTxAckPacket(int token, byte[] gatewayEui, byte[] jsonBytes) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] packet = new byte[4 + 8 + jsonBytes.length]; + packet[0] = (byte) (VERSION & 0xFF); + packet[1] = (byte) ((token >> 8) & 0xFF); + packet[2] = (byte) (token & 0xFF); + packet[3] = (byte) (TX_ACK & 0xFF); + + System.arraycopy(gatewayEui, 0, packet, 4, 8); + System.arraycopy(jsonBytes, 0, packet, 12, jsonBytes.length); + + return packet; + } + + // ----------------------------- + // UDP helpers + ACK validation + // ----------------------------- + + private byte[] receiveUdp(DatagramSocket socket, int maxBytes) throws IOException { + byte[] buffer = new byte[maxBytes]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + socket.receive(packet); + + byte[] data = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), data, 0, packet.getLength()); + return data; + } + + private void validatePushAck(byte[] ackBytes, int expectedToken) { + Assertions.assertNotNull(ackBytes); + Assertions.assertTrue(ackBytes.length >= 4, "PUSH_ACK must be at least 4 bytes"); + + int version = ackBytes[0] & 0xFF; + Assertions.assertEquals(VERSION, version, "Semtech protocol version mismatch"); + + int token = ((ackBytes[1] & 0xFF) << 8) | (ackBytes[2] & 0xFF); + Assertions.assertEquals(expectedToken, token, "PUSH_ACK token mismatch"); + + int identifier = ackBytes[3] & 0xFF; + Assertions.assertEquals(PUSH_ACK, identifier, "Expected PUSH_ACK (0x01)"); + } + + private void validatePullAck(byte[] ackBytes, int expectedToken) { + Assertions.assertNotNull(ackBytes); + Assertions.assertTrue(ackBytes.length >= 4, "PULL_ACK must be at least 4 bytes"); + + int version = ackBytes[0] & 0xFF; + Assertions.assertEquals(VERSION, version, "Semtech protocol version mismatch"); + + int token = ((ackBytes[1] & 0xFF) << 8) | (ackBytes[2] & 0xFF); + Assertions.assertEquals(expectedToken, token, "PULL_ACK token mismatch"); + + int identifier = ackBytes[3] & 0xFF; + Assertions.assertEquals(PULL_ACK, identifier, "Expected PULL_ACK (0x04)"); + } + + // ----------------------------- + // Strict POJO assertions + // ----------------------------- + + private void assertRxpkCount(PushDataJSON received, int expectedCount) { + Assertions.assertNotNull(received.getRxpk(), "rxpk must not be null"); + Assertions.assertEquals(expectedCount, received.getRxpk().length, "rxpk count mismatch"); + } + + private void assertReceivePacketLoRaEquals(ReceivePacket expected, ReceivePacket actual) { + Assertions.assertNotNull(actual, "ReceivePacket must not be null"); + + Assertions.assertEquals(expected.getTime(), actual.getTime(), "time mismatch"); + Assertions.assertEquals(expected.getTmst(), actual.getTmst(), "tmst mismatch"); + Assertions.assertEquals(expected.getChan(), actual.getChan(), "chan mismatch"); + Assertions.assertEquals(expected.getRfch(), actual.getRfch(), "rfch mismatch"); + Assertions.assertEquals(expected.getStat(), actual.getStat(), "stat mismatch"); + Assertions.assertEquals(expected.getModu(), actual.getModu(), "modu mismatch"); + Assertions.assertEquals(expected.getDatr(), actual.getDatr(), "datr_s mismatch"); + Assertions.assertEquals(expected.getCodr(), actual.getCodr(), "codr mismatch"); + Assertions.assertEquals(expected.getRssi(), actual.getRssi(), "rssi mismatch"); + Assertions.assertEquals(expected.getSize(), actual.getSize(), "size mismatch"); + Assertions.assertEquals(expected.getData(), actual.getData(), "data mismatch"); + + assertDoubleEquals(expected.getFreq(), actual.getFreq(), 0.000001, "freq mismatch"); + assertDoubleEquals(expected.getLsnr(), actual.getLsnr(), 0.000001, "lsnr mismatch"); + } + + private void assertDoubleEquals(double expected, double actual, double delta, String message) { + Assertions.assertTrue(Math.abs(expected - actual) <= delta, message + " expected=" + expected + " actual=" + actual); + } + + private ReceivePacket buildExpectedReceivePacketLoRa( + String time, + long tmst, + double freq, + long chan, + long rfch, + long stat, + String modu, + String datr, + String codr, + long rssi, + double lsnr, + long size, + String dataBase64) { + + ReceivePacket p = new ReceivePacket(); + p.setTime(time); + p.setTmst(tmst); + p.setFreq(freq); + p.setChan(chan); + p.setRfch(rfch); + p.setStat(stat); + p.setModu(modu); + p.setDatr(datr); + p.setCodr(codr); + p.setRssi(rssi); + p.setLsnr(lsnr); + p.setSize(size); + p.setData(dataBase64); + return p; + } + + // ----------------------------- + // Incoming Semtech JSON (raw-ish) + // ----------------------------- + + private JsonObject buildIncomingSemtechLoRaJson() { + JsonObject root = new JsonObject(); + root.add("rxpk", GSON.fromJson(""" + [{ + "time":"2013-03-31T16:21:17.528002Z", + "tmst":3512348611, + "chan":2, + "rfch":0, + "freq":866.349812, + "stat":1, + "modu":"LORA", + "datr":"SF7BW125", + "codr":"4/6", + "rssi":-35, + "lsnr":5.1, + "size":15, + "data":"VEVTVF9QQUNLRVRfMTIzNA==" + }] + """, com.google.gson.JsonElement.class)); + return root; + } + + private JsonObject buildIncomingSemtechMultiLoRaJson() { + JsonObject root = new JsonObject(); + root.add("rxpk", GSON.fromJson(""" + [ + { + "time":"2013-03-31T16:21:17.528002Z", + "tmst":1111111111, + "chan":2, + "rfch":0, + "freq":866.349812, + "stat":1, + "modu":"LORA", + "datr":"SF7BW125", + "codr":"4/6", + "rssi":-35, + "lsnr":5.1, + "size":4, + "data":"AQIDBA==" + }, + { + "time":"2013-03-31T16:21:18.528002Z", + "tmst":2222222222, + "chan":3, + "rfch":1, + "freq":867.125, + "stat":1, + "modu":"LORA", + "datr":"SF12BW125", + "codr":"4/5", + "rssi":-80, + "lsnr":-1.25, + "size":5, + "data":"AQIDBAU=" + } + ] + """, com.google.gson.JsonElement.class)); + return root; + } + + private JsonObject buildIncomingSemtechStatOnlyJson() { + return GSON.fromJson(""" + { + "stat": { + "time":"2013-03-31T16:21:17.528002Z", + "lati":-33.1234, + "longitude":151.1234, + "alti":12, + "rxnb":10, + "rxok":9, + "rxfw":7, + "ackr":100.0, + "dwnb":2, + "txnb":1 + } + } + """, JsonObject.class); + } + + private JsonObject buildIncomingSemtechFskJson() { + return GSON.fromJson(""" + { + "rxpk": [{ + "time":"2013-03-31T16:21:17.528002Z", + "tmst":3333333333, + "chan":0, + "rfch":0, + "freq":868.3, + "stat":1, + "modu":"FSK", + "datr":50000, + "rssi":-42, + "lsnr":0.0, + "size":3, + "data":"AQID" + }] + } + """, JsonObject.class); + } + + private JsonObject buildIncomingSemtechCrcFailJson() { + return GSON.fromJson(""" + { + "rxpk": [{ + "time":"2013-03-31T16:21:17.528002Z", + "tmst":4444444444, + "chan":1, + "rfch":0, + "freq":866.9, + "stat":-1, + "modu":"LORA", + "datr":"SF9BW125", + "codr":"4/7", + "rssi":-90, + "lsnr":2.0, + "size":2, + "data":"AAE=" + }] + } + """, JsonObject.class); + } + + // ----------------------------- + // misc + // ----------------------------- + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + Assertions.assertEquals(0, length % 2, "hex string length must be even"); + + byte[] bytes = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + int value = Integer.parseInt(hex.substring(i, i + 2), 16); + bytes[i / 2] = (byte) value; + } + return bytes; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpTest.java new file mode 100644 index 000000000..ea8077101 --- /dev/null +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/semtech/SemtechViaUdpTest.java @@ -0,0 +1,248 @@ +package io.mapsmessaging.network.protocol.impl.semtech; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SubscriptionContextBuilder; +import io.mapsmessaging.api.features.ClientAcknowledgement; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.engine.destination.subscription.SubscriptionContext; +import io.mapsmessaging.test.BaseTestConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class SemtechViaUdpTest extends BaseTestConfig { + + /** + * Adjust to whatever namespace/topic your Semtech UDP listener publishes to. + * Examples: + * - "/semtech/#" + * - "/lora/semtech/#" + * - "/udp/semtech/#" + */ + private static final String SUBSCRIBE_TOPIC = "/semtech/#"; + + private static final String UDP_HOST = "127.0.0.1"; + private static final int UDP_PORT = 1700; + + private static final int SEMTECH_PROTOCOL_VERSION = 0x02; + + private static final int PUSH_DATA = 0x00; + private static final int PUSH_ACK = 0x01; + + @Test + void testPushDataOverUdpPublishesAndAcked() throws LoginException, IOException, InterruptedException { + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch firstMessageLatch = new CountDownLatch(1); + List messages = new CopyOnWriteArrayList<>(); + + MessageListener listener = messageEvent -> { + receivedCount.incrementAndGet(); + messages.add(messageEvent.getMessage()); + firstMessageLatch.countDown(); + messageEvent.getCompletionTask().run(); + }; + + Session session = createSession("semtechUdpSimpleTest" + System.nanoTime(), 60, 60, false, listener); + Assertions.assertNotNull(session); + + try { + SubscriptionContextBuilder subscriptionContextBuilder = new SubscriptionContextBuilder(SUBSCRIBE_TOPIC, ClientAcknowledgement.AUTO); + + SubscriptionContext context = subscriptionContextBuilder + .setQos(QualityOfService.AT_MOST_ONCE) + .setReceiveMaximum(100) + .build(); + + session.addSubscription(context); + + byte[] gatewayEui = hexToBytes("0102030405060708"); // pick any 8 bytes; used by server for routing + int token = ThreadLocalRandom.current().nextInt(0, 0x10000); + + JsonObject pushJson = buildMinimalRxpkPushDataJson(); + byte[] pushPacket = buildPushDataPacket(token, gatewayEui, pushJson); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(2000); + + InetAddress address = InetAddress.getByName(UDP_HOST); + DatagramPacket packet = new DatagramPacket(pushPacket, pushPacket.length, address, UDP_PORT); + socket.send(packet); + + byte[] ackBytes = receiveUdp(socket, 64); + validatePushAck(ackBytes, token); + } + + boolean received = firstMessageLatch.await(5, TimeUnit.SECONDS); + Assertions.assertTrue(received, "No Semtech messages were published after UDP injection to port " + UDP_PORT); + Assertions.assertTrue(receivedCount.get() > 0, "Expected at least one message after UDP injection"); + + int attempts = 0; + do { + delay(100); + attempts++; + } while (messages.isEmpty() && attempts < 20); + + session.removeSubscription(context.getKey()); + + Assertions.assertEquals(1, messages.size(), "Expected exactly one message after single PUSH_DATA injection"); + validateSemtechJsonPayloadContainsInjectedRxpk(messages.getFirst(), pushJson); + } finally { + close(session); + } + } + + private JsonObject buildMinimalRxpkPushDataJson() { + JsonObject root = new JsonObject(); + JsonArray rxpk = new JsonArray(); + + JsonObject one = new JsonObject(); + one.addProperty("time", "2013-03-31T16:21:17.528002Z"); + one.addProperty("tmst", 3512348611L); + one.addProperty("chan", 2); + one.addProperty("rfch", 0); + one.addProperty("freq", 866.349812); + one.addProperty("stat", 1); + one.addProperty("modu", "LORA"); + one.addProperty("datr", "SF7BW125"); + one.addProperty("codr", "4/6"); + one.addProperty("rssi", -35); + one.addProperty("lsnr", 5.1); + + // payload: arbitrary bytes, base64-encoded. + // size must match decoded byte length. + String base64 = "VEVTVF9QQUNLRVRfMTIzNA=="; // "TEST_PACKET_1234" + one.addProperty("size", 15); + one.addProperty("data", base64); + + rxpk.add(one); + root.add("rxpk", rxpk); + + return root; + } + + private byte[] buildPushDataPacket(int token, byte[] gatewayEui, JsonObject json) { + Assertions.assertNotNull(gatewayEui); + Assertions.assertEquals(8, gatewayEui.length, "gatewayEui must be 8 bytes"); + + byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); + byte[] packet = new byte[4 + 8 + jsonBytes.length]; + + packet[0] = (byte) (SEMTECH_PROTOCOL_VERSION & 0xFF); + packet[1] = (byte) ((token >> 8) & 0xFF); + packet[2] = (byte) (token & 0xFF); + packet[3] = (byte) (PUSH_DATA & 0xFF); + + System.arraycopy(gatewayEui, 0, packet, 4, 8); + System.arraycopy(jsonBytes, 0, packet, 12, jsonBytes.length); + + return packet; + } + + private byte[] receiveUdp(DatagramSocket socket, int maxBytes) throws IOException { + byte[] buffer = new byte[maxBytes]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + socket.receive(packet); + + byte[] data = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), data, 0, packet.getLength()); + return data; + } + + private void validatePushAck(byte[] ackBytes, int expectedToken) { + Assertions.assertNotNull(ackBytes); + Assertions.assertTrue(ackBytes.length >= 4, "PUSH_ACK must be at least 4 bytes"); + + int version = ackBytes[0] & 0xFF; + Assertions.assertEquals(SEMTECH_PROTOCOL_VERSION, version, "Semtech protocol version mismatch"); + + int token = ((ackBytes[1] & 0xFF) << 8) | (ackBytes[2] & 0xFF); + Assertions.assertEquals(expectedToken, token, "PUSH_ACK token mismatch"); + + int identifier = ackBytes[3] & 0xFF; + Assertions.assertEquals(PUSH_ACK, identifier, "Expected PUSH_ACK (0x01)"); + } + + private void validateSemtechJsonPayloadContainsInjectedRxpk(Message message, JsonObject injectedPushJson) { + Assertions.assertNotNull(message, "Message must not be null"); + + byte[] payloadBytes = message.getOpaqueData(); + Assertions.assertNotNull(payloadBytes, "Message payload must not be null"); + Assertions.assertTrue(payloadBytes.length > 0, "Message payload must not be empty"); + + String payload = new String(payloadBytes, StandardCharsets.UTF_8).trim(); + Assertions.assertFalse(payload.isEmpty(), "Message payload must not be blank"); + + JsonElement root; + try { + root = JsonParser.parseString(payload); + } catch (Exception e) { + Assertions.fail("Payload is not valid JSON. Payload: " + payload, e); + return; + } + + Assertions.assertTrue(root.isJsonObject(), "Payload must be a JSON object. Payload: " + payload); + JsonObject object = root.getAsJsonObject(); + + // This assumes your Semtech implementation publishes raw Semtech JSON or wraps it. + // First try direct "rxpk" at top-level, then try common wrapper names. + JsonObject semtechObject = object; + if (!object.has("rxpk")) { + if (object.has("semtech") && object.get("semtech").isJsonObject()) { + semtechObject = object.getAsJsonObject("semtech"); + } else if (object.has("lora") && object.get("lora").isJsonObject()) { + semtechObject = object.getAsJsonObject("lora"); + } + } + + assertFieldPresent(semtechObject, "rxpk"); + Assertions.assertTrue(semtechObject.get("rxpk").isJsonArray(), "rxpk must be an array. Payload: " + payload); + + JsonArray rxpk = semtechObject.getAsJsonArray("rxpk"); + Assertions.assertTrue(rxpk.size() >= 1, "rxpk must contain at least one item. Payload: " + payload); + + JsonObject first = rxpk.get(0).getAsJsonObject(); + assertFieldPresent(first, "data"); + assertFieldPresent(first, "size"); + + String injectedBase64 = injectedPushJson.getAsJsonArray("rxpk").get(0).getAsJsonObject().get("data").getAsString(); + int injectedSize = injectedPushJson.getAsJsonArray("rxpk").get(0).getAsJsonObject().get("size").getAsInt(); + + Assertions.assertEquals(injectedBase64, first.get("data").getAsString(), "Injected base64 payload mismatch. Payload: " + payload); + Assertions.assertEquals(injectedSize, first.get("size").getAsInt(), "Injected size mismatch. Payload: " + payload); + } + + private void assertFieldPresent(JsonObject object, String fieldName) { + Assertions.assertTrue(object.has(fieldName), "Missing field '" + fieldName + "'. Object: " + object); + Assertions.assertFalse(object.get(fieldName).isJsonNull(), "Field '" + fieldName + "' must not be null. Object: " + object); + } + + private byte[] hexToBytes(String hex) { + int length = hex.length(); + Assertions.assertEquals(0, length % 2, "hex string length must be even"); + + byte[] bytes = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + int value = Integer.parseInt(hex.substring(i, i + 2), 16); + bytes[i / 2] = (byte) value; + } + return bytes; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExpiredEventTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExpiredEventTest.java index e6239e6c5..9320d3f57 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExpiredEventTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExpiredEventTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExtendedSelectorTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExtendedSelectorTest.java index 9e56e394f..391e10479 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExtendedSelectorTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/ExtendedSelectorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/SimpleBufferBasedStompIT.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/SimpleBufferBasedStompIT.java index 1af7a84b2..dbd3e3181 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/SimpleBufferBasedStompIT.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/SimpleBufferBasedStompIT.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompBaseTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompBaseTest.java index 528d029eb..b5aee3cd3 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompBaseTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompBaseTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompConnectionTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompConnectionTest.java index 8c7082076..c46bbe752 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompConnectionTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompConnectionTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompPublishEventTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompPublishEventTest.java index fab90426c..9c293ba93 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompPublishEventTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompPublishEventTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompQueueTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompQueueTest.java index 102cb83e8..5a6d6e01c 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompQueueTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompQueueTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalPublishTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalPublishTest.java index 203f4b585..4e1f7f762 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalPublishTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalPublishTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalSubscriptionImplTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalSubscriptionImplTest.java index ae498df7e..8b37b8085 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalSubscriptionImplTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/StompTransactionalSubscriptionImplTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/WebSocketTest.java b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/WebSocketTest.java index 0b33faebe..541d91820 100644 --- a/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/WebSocketTest.java +++ b/src/test/java/io/mapsmessaging/network/protocol/impl/stomp/WebSocketTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/network/route/LinkSelectorDecisionTest.java b/src/test/java/io/mapsmessaging/network/route/LinkSelectorDecisionTest.java index 132cc9352..1c5276da2 100644 --- a/src/test/java/io/mapsmessaging/network/route/LinkSelectorDecisionTest.java +++ b/src/test/java/io/mapsmessaging/network/route/LinkSelectorDecisionTest.java @@ -1,3 +1,22 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.network.route; import io.mapsmessaging.network.route.link.Link; diff --git a/src/test/java/io/mapsmessaging/network/route/LinkSelectorEdgeCasesTest.java b/src/test/java/io/mapsmessaging/network/route/LinkSelectorEdgeCasesTest.java index dd246f8ee..c23f93712 100644 --- a/src/test/java/io/mapsmessaging/network/route/LinkSelectorEdgeCasesTest.java +++ b/src/test/java/io/mapsmessaging/network/route/LinkSelectorEdgeCasesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/rest/ApiTestBase.java b/src/test/java/io/mapsmessaging/rest/ApiTestBase.java new file mode 100644 index 000000000..d8cea45ab --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/ApiTestBase.java @@ -0,0 +1,145 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest; + + +import com.atlassian.oai.validator.restassured.OpenApiValidationFilter; +import io.mapsmessaging.test.BaseTestConfig; +import io.restassured.RestAssured; +import io.restassured.http.Cookies; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; + +public abstract class ApiTestBase extends BaseTestConfig { + + protected static String baseUrl; + protected static Cookies authCookies; + protected static OpenApiValidationFilter openApi; + + protected static boolean LOADED_OPENAPI = false; + private static final Path OPENAPI_PATH = + Path.of("src", "test", "resources", "openapi.json"); + + @BeforeAll + static void initClient() throws IOException { + baseUrl = System.getProperty("BASE_URL", System.getenv().getOrDefault("BASE_URL", "http://localhost:8080")); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + waitUntilHealthy(Duration.ofSeconds(30)); + fetchOpenApiSpec(); + openApi = new OpenApiValidationFilter(OPENAPI_PATH.toString()); + authCookies = CookieAuth.login(baseUrl, "admin", getPassword("admin")); + } + + protected static io.restassured.specification.RequestSpecification givenAnonymous() { + return RestAssured + .given() + .baseUri(baseUrl) + .filter(openApi); + } + + protected static io.restassured.specification.RequestSpecification givenAuthenticated() { + return RestAssured + .given() + .baseUri(baseUrl) + .cookies(authCookies) + .filter(openApi); + } + + protected RequestSpecification givenAuthenticatedNoValidation() { + return RestAssured.given() + .baseUri(baseUrl) + .cookies(authCookies); + } + + private static void fetchOpenApiSpec() throws IOException { + if(LOADED_OPENAPI) return; + try(HttpClient client = HttpClient.newHttpClient() ){ + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/openapi.json")) + .timeout(Duration.ofSeconds(60)) + .GET() + .build(); + + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IOException("Failed to fetch OpenAPI spec. HTTP " + response.statusCode()); + } + + Files.createDirectories(OPENAPI_PATH.getParent()); + + try (InputStream body = response.body()) { + Files.copy(body, OPENAPI_PATH, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + LOADED_OPENAPI = true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while fetching OpenAPI spec", e); + } + } + } + + private static void waitUntilHealthy(Duration timeout) { + Instant deadline = Instant.now().plus(timeout); + Throwable lastError = null; + + while (Instant.now().isBefore(deadline)) { + try { + // Change "/health" to whatever your server exposes. + int status = RestAssured + .given() + .baseUri(baseUrl) + .get("/health") + .getStatusCode(); + + if (status >= 200 && status < 500) { + return; + } + } catch (Throwable t) { + lastError = t; + } + + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for server health", e); + } + } + + RuntimeException e = new RuntimeException("Server not healthy at " + baseUrl + " within " + timeout); + if (lastError != null) { + e.addSuppressed(lastError); + } + throw e; + } +} diff --git a/src/test/java/io/mapsmessaging/rest/CookieAuth.java b/src/test/java/io/mapsmessaging/rest/CookieAuth.java new file mode 100644 index 000000000..0ed2cf055 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/CookieAuth.java @@ -0,0 +1,61 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest; + + +import io.restassured.RestAssured; +import io.restassured.http.Cookies; +import io.restassured.response.Response; + +import java.util.Objects; + +public final class CookieAuth { + + private CookieAuth() { + } + + public static Cookies login(String baseUrl, String username, String password) { + Objects.requireNonNull(baseUrl, "baseUrl"); + Objects.requireNonNull(username, "username"); + Objects.requireNonNull(password, "password"); + + // Adjust to your login endpoint + payload format. + Response response = RestAssured + .given() + .baseUri(baseUrl) + .contentType("application/json") + .body("{\"username\":\"" + escapeJson(username) + "\",\"password\":\"" + escapeJson(password) + "\"}") + .post("/api/v1/login") + .then() + .statusCode(200) + .extract() + .response(); + + Cookies cookies = response.detailedCookies(); + if (cookies == null || cookies.asList().isEmpty()) { + throw new IllegalStateException("Login returned 200 but no cookies were set"); + } + return cookies; + } + + private static String escapeJson(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/rest/SmokeTest.java b/src/test/java/io/mapsmessaging/rest/SmokeTest.java new file mode 100644 index 000000000..0fd98ecda --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/SmokeTest.java @@ -0,0 +1,41 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest; + +import org.junit.jupiter.api.Test; + +public class SmokeTest extends ApiTestBase { + + @Test + void health_isOk() { + givenAnonymous() + .get("/api/v1/ping") + .then() + .statusCode(200); + } + + @Test + void authenticatedEndpoint_isOk() { + givenAuthenticated() + .get("/api/v1/auth/users") + .then() + .statusCode(200); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/auth/AuthorisationResourceTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/auth/AuthorisationResourceTest.java new file mode 100644 index 000000000..1fcc51c36 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/auth/AuthorisationResourceTest.java @@ -0,0 +1,226 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.auth; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class AuthorisationResourceTest extends ApiTestBase { + + private static final String PERMISSIONS_PATH = "/api/v1/auth/permissions"; + private static final String RESOURCE_ACL_PATH = "/api/v1/auth/resources/acl"; + private static final String IDENTITY_ACL_PATH = "/api/v1/auth/identities"; + private static final String GROUP_ACL_PATH = "/api/v1/auth/groups"; + private static final String USERS_PATH = "/api/v1/auth/users"; + private static final String GROUPS_PATH = "/api/v1/auth/groups"; + private static final String ACL_CHECK_PATH = "/api/v1/auth/acl/check"; + private static final String RESOURCE_TYPE_PARAM = "resourceType"; + private static final String RESOURCE_KEY_PARAM = "resourceKey"; + + @Test + void getPermissions_returns200AndJson() { + givenAuthenticated() + .when() + .get(PERMISSIONS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void getResourceAcl_blankResourceType_returns400() { + givenAuthenticatedNoValidation() + .queryParam(RESOURCE_TYPE_PARAM, " ") + .queryParam(RESOURCE_KEY_PARAM, "/some/resource") + .when() + .get(RESOURCE_ACL_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getResourceAcl_blankResourceKey_returns400() { + givenAuthenticatedNoValidation() + .queryParam(RESOURCE_TYPE_PARAM, "TOPIC") + .queryParam(RESOURCE_KEY_PARAM, " ") + .when() + .get(RESOURCE_ACL_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + @Test + void getResourceAcl_invalidResourceType_returns400() { + givenAuthenticated() + .queryParam(RESOURCE_TYPE_PARAM, "NOT_A_REAL_TYPE") + .queryParam(RESOURCE_KEY_PARAM, "/it/nonexistent/" + UUID.randomUUID()) + .when() + .get(RESOURCE_ACL_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getIdentityAcl_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .get(IDENTITY_ACL_PATH + "/not-a-uuid/acl") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getIdentityAcl_existingUser_returns200() { + UUID userUuid = findAnyExistingUserUuid(); + Assertions.assertNotNull(userUuid, "No users returned by /auth/users, cannot test identity ACL"); + + givenAuthenticated() + .when() + .get(IDENTITY_ACL_PATH + "/" + userUuid + "/acl") + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void getGroupAcl_existingGroup_returns200() { + UUID groupUuid = findAnyExistingGroupUuid(); + Assertions.assertNotNull(groupUuid, "No groups returned by /auth/groups, cannot test group ACL"); + + givenAuthenticatedNoValidation() + .when() + .get(GROUP_ACL_PATH + "/" + groupUuid ) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void updateResourceAcl_missingBody_returns400_withoutValidator() { + givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .put(RESOURCE_ACL_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void checkAccess_missingBody_returns400_withoutValidator() { + givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .post(ACL_CHECK_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + private UUID findAnyExistingUserUuid() { + Response response = givenAuthenticated() + .when() + .get(USERS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + List list = jsonPath.getList("$"); + if (list == null || list.isEmpty()) { + return null; + } + + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String uuidText = getFirstString(map, "uniqueId", "userUuid", "id"); + if (uuidText != null && !uuidText.isBlank()) { + return UUID.fromString(uuidText); + } + } + + return null; + } + + private UUID findAnyExistingGroupUuid() { + Response response = givenAuthenticated() + .when() + .get(GROUPS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + List list = jsonPath.getList("$"); + if (list == null || list.isEmpty()) { + return null; + } + + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String uuidText = getFirstString(map, "uniqueId", "groupUuid", "id"); + if (uuidText != null && !uuidText.isBlank()) { + return UUID.fromString(uuidText); + } + } + + return null; + } + + private String getFirstString(Map map, String firstKey, String secondKey, String thirdKey) { + Object value = map.get(firstKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(secondKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(thirdKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + return null; + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/auth/GroupManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/auth/GroupManagementApiTest.java new file mode 100644 index 000000000..728651aa2 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/auth/GroupManagementApiTest.java @@ -0,0 +1,316 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.auth; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +class GroupManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/auth/groups"; + private static final String USERS_PATH = "/api/v1/auth/users"; + + @Test + void create_get_addUser_removeUser_delete_happyPath() { + String groupName = "it_group_" + Instant.now().getEpochSecond(); + + createGroup(groupName); + + UUID groupUuid = findGroupUuidByName(groupName); + Assertions.assertNotNull(groupUuid, "Created group not found in group list: " + groupName); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + groupUuid) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + + UUID userUuid = findAnyExistingUserUuid(); + Assertions.assertNotNull(userUuid, "No users returned by /auth/users, cannot test membership endpoints"); + + givenAuthenticated() + .when() + .post(BASE_PATH + "/" + groupUuid + "/" + userUuid) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + groupUuid + "/" + userUuid) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + + deleteGroup(groupUuid, groupName); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + groupUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void getAllGroups_invalidFilter_returns400() { + givenAuthenticated() + .queryParam("filter", "name = 'x' and (") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getGroup_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .get(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getGroup_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + missingUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void createGroup_blankName_returns400() { + givenAuthenticated() + .contentType("text/plain") + .body(" ") + .when() + .post(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void createGroup_duplicate_returns409() { + String groupName = "it_group_dup" + Instant.now().getEpochSecond(); + + createGroup(groupName); + + givenAuthenticated() + .contentType("text/plain") + .body(groupName) + .when() + .post(BASE_PATH) + .then() + .statusCode(409) + .contentType(ContentType.JSON); + + UUID groupUuid = findGroupUuidByName(groupName); + deleteGroup(groupUuid, groupName); + } + + @Test + void deleteGroup_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .delete(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void deleteGroup_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + missingUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void addUserToGroup_invalidUuid_returns400() { + UUID validGroupUuid = UUID.randomUUID(); + + givenAuthenticatedNoValidation() + .when() + .post(BASE_PATH + "/" + validGroupUuid + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void addUserToGroup_groupNotFound_returns404() { + UUID missingGroupUuid = UUID.randomUUID(); + UUID anyUserUuid = findAnyExistingUserUuid(); + Assertions.assertNotNull(anyUserUuid); + + givenAuthenticated() + .when() + .post(BASE_PATH + "/" + missingGroupUuid + "/" + anyUserUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void removeUserFromGroup_groupNotFound_returns404() { + UUID missingGroupUuid = UUID.randomUUID(); + UUID anyUserUuid = findAnyExistingUserUuid(); + Assertions.assertNotNull(anyUserUuid); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + missingGroupUuid + "/" + anyUserUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + private void createGroup(String groupName) { + Assertions.assertNotNull(groupName); + Assertions.assertTrue(groupName.startsWith("it_"), "Refusing to create non-test group: " + groupName); + + givenAuthenticated() + .contentType("text/plain") + .body(groupName) + .when() + .post(BASE_PATH) + .then() + .statusCode(201) + .contentType(ContentType.JSON); + } + + private void deleteGroup(UUID groupUuid, String groupName) { + Assertions.assertNotNull(groupUuid); + Assertions.assertNotNull(groupName); + Assertions.assertTrue(groupName.startsWith("it_"), "Refusing to delete non-test group: " + groupName); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + groupUuid) + .then() + .statusCode(204); + } + + private UUID findGroupUuidByName(String groupName) { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + List list = jsonPath.getList("$"); + if (list == null || list.isEmpty()) { + return null; + } + + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String name = getFirstString(map, "name", "groupName", "displayName"); + if (name == null || !name.equals(groupName)) { + continue; + } + + String uuidText = getFirstString(map, "uniqueId", "groupUuid", "id"); + if (uuidText != null && !uuidText.isBlank()) { + return UUID.fromString(uuidText); + } + } + + return null; + } + + private UUID findAnyExistingUserUuid() { + Response response = givenAuthenticated() + .when() + .get(USERS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + List list = jsonPath.getList("$"); + if (list == null || list.isEmpty()) { + return null; + } + + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String uuidText = getFirstString(map, "uniqueId", "userUuid", "id"); + if (uuidText != null && !uuidText.isBlank()) { + return UUID.fromString(uuidText); + } + } + + return null; + } + + private String getFirstString(Map map, String firstKey, String secondKey, String thirdKey) { + Object value = map.get(firstKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(secondKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(thirdKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + return null; + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/auth/LockedUsersApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/auth/LockedUsersApiTest.java new file mode 100644 index 000000000..c39bf9d6a --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/auth/LockedUsersApiTest.java @@ -0,0 +1,129 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.auth; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +class LockedUsersApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/auth/user-lockouts"; + private static final String USERS_PATH = "/api/v1/auth/users"; + + @Test + void getAllLockedUsers_returns200AndJson() { + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void unlockUser_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .delete(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void unlockUser_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + missingUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void unlockUser_existingUser_returns200() { + UUID anyUserUuid = findAnyExistingUserUuid(); + Assertions.assertNotNull(anyUserUuid, "No users returned by /auth/users, cannot test unlock endpoint"); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + anyUserUuid) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + private UUID findAnyExistingUserUuid() { + Response response = givenAuthenticated() + .when() + .get(USERS_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + List list = jsonPath.getList("$"); + if (list == null || list.isEmpty()) { + return null; + } + + for (Object item : list) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String uuidText = getFirstString(map, "uniqueId", "userUuid", "id"); + if (uuidText != null && !uuidText.isBlank()) { + return UUID.fromString(uuidText); + } + } + + return null; + } + + private String getFirstString(Map map, String firstKey, String secondKey, String thirdKey) { + Object value = map.get(firstKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(secondKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + value = map.get(thirdKey); + if (value instanceof String stringValue && !stringValue.isBlank()) { + return stringValue; + } + return null; + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/auth/UserManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/auth/UserManagementApiTest.java new file mode 100644 index 000000000..3b02cf2b0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/auth/UserManagementApiTest.java @@ -0,0 +1,301 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.auth; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +class UserManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/auth/users"; + + @Test + void create_get_changePassword_delete_happyPath() { + String username = "it_user_" + System.currentTimeMillis(); + String initialPassword = "it_pw_" + System.currentTimeMillis(); + String newPassword = "it_pw2_" + System.currentTimeMillis(); + + createUser(username, initialPassword); + + UUID userUuid = findUserUuidByUsername(username); + Assertions.assertNotNull(userUuid); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + userUuid) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + + JsonPath getUserJson = givenAuthenticated() + .when() + .get(BASE_PATH + "/" + userUuid) + .then() + .statusCode(200) + .extract() + .jsonPath(); + + String returnedUsername = getFirstString(getUserJson, "username"); + Assertions.assertEquals(username, returnedUsername); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"newPassword\":\"" + newPassword + "\"}") + .when() + .put(BASE_PATH + "/" + userUuid + "/password") + .then() + .statusCode(204); + + deleteUser(userUuid, username); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + userUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void getAllUsers_invalidFilter_returns400() { + givenAuthenticated() + .queryParam("filter", "username = 'x' and (") // intentionally broken selector + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getUser_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .get(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void getUser_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + missingUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void createUser_blankUsername_returns400() { + givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"username\":\" \",\"password\":\"pw\"}") + .when() + .post(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void createUser_missingPassword_returns400() { + givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .body("{\"username\":\"it_user_" + System.currentTimeMillis() + "\"}") + .when() + .post(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void createUser_conflict_returns409() { + String username = "it_user_" + System.currentTimeMillis(); + String password = "it_pw_" + System.currentTimeMillis(); + + createUser(username, password); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}") + .when() + .post(BASE_PATH) + .then() + .statusCode(409) + .contentType(ContentType.JSON); + + UUID userUuid = findUserUuidByUsername(username); + deleteUser(userUuid, username); + } + + @Test + void deleteUser_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .when() + .delete(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void deleteUser_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + missingUuid) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + @Test + void changePassword_invalidUuid_returns400() { + givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .body("{\"newPassword\":\"pw\"}") + .when() + .put(BASE_PATH + "/not-a-uuid/password") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void changePassword_blankNewPassword_returns400() { + UUID uuid = UUID.randomUUID(); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"newPassword\":\" \"}") + .when() + .put(BASE_PATH + "/" + uuid + "/password") + .then() + .statusCode(400) + .contentType(ContentType.JSON); + } + + @Test + void changePassword_notFound_returns404() { + UUID missingUuid = UUID.randomUUID(); + + givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"newPassword\":\"it_pw_" + System.currentTimeMillis() + "\"}") + .when() + .put(BASE_PATH + "/" + missingUuid + "/password") + .then() + .statusCode(404) + .contentType(ContentType.JSON); + } + + private void createUser(String username, String password) { + Assertions.assertTrue(username.startsWith("it_") || username.startsWith("it")); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}") + .when() + .post(BASE_PATH) + .then() + .statusCode(201) + .contentType(ContentType.JSON) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + } + + private UUID findUserUuidByUsername(String username) { + String filter = "username = '" + username + "'"; + + JsonPath jsonPath = givenAuthenticated() + .queryParam("filter", filter) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .jsonPath(); + + int size = jsonPath.getList("$").size(); + Assertions.assertTrue(size >= 1); + + String uuidText = firstNonBlank( + jsonPath.getString("[0].uniqueId"), + jsonPath.getString("[0].id"), + jsonPath.getString("[0].uuid") + ); + + Assertions.assertNotNull(uuidText); + return UUID.fromString(uuidText); + } + + private void deleteUser(UUID userUuid, String username) { + Assertions.assertNotNull(userUuid); + Assertions.assertNotNull(username); + Assertions.assertTrue(username.startsWith("it_")); + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + userUuid) + .then() + .statusCode(204); + } + + private String getFirstString(JsonPath jsonPath, String fieldName) { + Object value = jsonPath.get(fieldName); + if (value == null) { + return null; + } + if (value instanceof String stringValue) { + return stringValue; + } + return String.valueOf(value); + } + + private String firstNonBlank(String firstValue, String secondValue, String thirdValue) { + if (firstValue != null && !firstValue.isBlank()) { + return firstValue; + } + if (secondValue != null && !secondValue.isBlank()) { + return secondValue; + } + if (thirdValue != null && !thirdValue.isBlank()) { + return thirdValue; + } + return null; + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/config/ConfigManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/config/ConfigManagementApiTest.java new file mode 100644 index 000000000..71d732f0b --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/config/ConfigManagementApiTest.java @@ -0,0 +1,176 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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. + */ + +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.config; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; + +public class ConfigManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server/config"; + + @Test + void listConfigSections_returns200_andNonEmptyArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + String firstSectionName = getFirstStringFromArray(response); + Assertions.assertNotNull(firstSectionName); + Assertions.assertFalse(firstSectionName.isBlank()); + } + + @Test + void getConfigSection_returns200_forKnownSection_fromList() { + Response listResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + String sectionName = getFirstStringFromArray(listResponse); + Assertions.assertNotNull(sectionName); + Assertions.assertFalse(sectionName.isBlank()); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + urlEncode(sectionName)) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + Object configObject = jsonPath.get("config"); + Object schemaObject = jsonPath.get("schema"); + + Assertions.assertNotNull(configObject); + Assertions.assertNotNull(schemaObject); + } + + @Test + void getConfigSection_returns400_forBlankName() { + String encodedBlank = urlEncode(" "); + Response response = givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + encodedBlank) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getConfigSection_returns404_forUnknownSection() { + String unknownSection = "it_unknown_" + Instant.now().toEpochMilli(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + urlEncode(unknownSection)) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private String getFirstStringFromArray(Response response) { + JsonPath jsonPath = response.jsonPath(); + Object raw = jsonPath.get("$"); + if (raw == null) { + return null; + } + if (raw instanceof java.util.List list) { + for (Object item : list) { + if (item instanceof String value && !value.isBlank()) { + return value; + } + } + return null; + } + if (raw instanceof String[] values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + return null; + } + + private boolean hasNonBlankStatusMessage(Response response) { + JsonPath jsonPath = response.jsonPath(); + String status = jsonPath.getString("status"); + return status != null && !status.isBlank(); + } + + private String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/connections/ConnectionManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/connections/ConnectionManagementApiTest.java new file mode 100644 index 000000000..56500e1d9 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/connections/ConnectionManagementApiTest.java @@ -0,0 +1,189 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.connections; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class ConnectionManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/connections"; + + @Test + void getAllConnections_returns200_andMatchesOpenApi() { + Response response = + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(response.asString().startsWith("[")); + } + + @Test + void getAllConnections_withInvalidFilter_returns400_withStatusResponse() { + StatusResponse statusResponse = + givenAuthenticated() + .queryParam("filter", "this is not a valid selector !!!!") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + Assertions.assertTrue(statusResponse.getStatus() != null && !statusResponse.getStatus().isBlank()); + } + + @Test + void getConnectionDetails_withInvalidConnectionId_returns400_withStatusResponse() { + StatusResponse statusResponse = + givenAuthenticated() + .when() + .get(BASE_PATH + "/not-a-number") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + Assertions.assertTrue(statusResponse.getStatus() != null && !statusResponse.getStatus().isBlank()); + } + + @Test + void getConnectionDetails_withNotFoundConnectionId_returns404_withStatusResponse() { + String neverExistsConnectionId = "9223372036854775807"; + + StatusResponse statusResponse = + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + neverExistsConnectionId) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + Assertions.assertTrue(statusResponse.getStatus() != null && !statusResponse.getStatus().isBlank()); + } + + @Test + void closeSpecificConnection_withInvalidConnectionId_returns400_withStatusResponse() { + StatusResponse statusResponse = + givenAuthenticated() + .when() + .delete(BASE_PATH + "/not-a-number") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + Assertions.assertTrue(statusResponse.getStatus() != null && !statusResponse.getStatus().isBlank()); + } + + @Test + void closeSpecificConnection_withNotFoundConnectionId_returns404_withStatusResponse() { + String neverExistsConnectionId = "9223372036854775807"; + + StatusResponse statusResponse = + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + neverExistsConnectionId) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + Assertions.assertTrue(statusResponse.getStatus() != null && !statusResponse.getStatus().isBlank()); + } + + @Test + void closeSpecificConnection_happyPathIfAnyConnectionExists_elseSkip() { + Response response = + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + List connectionIds = response.jsonPath().getList("id", String.class); + if (connectionIds == null || connectionIds.isEmpty()) { + return; + } + + String connectionId = getFirstString(connectionIds); + if (connectionId == null || connectionId.isBlank()) { + return; + } + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + connectionId) + .then() + .statusCode(404) + .contentType(ContentType.JSON); + + StatusResponse statusResponse = + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + connectionId) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .as(StatusResponse.class); + + Assertions.assertNotNull(statusResponse); + } + + private String getFirstString(List values) { + if (values == null || values.isEmpty()) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/destination/DestinationListManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/destination/DestinationListManagementApiTest.java new file mode 100644 index 000000000..816fef0cb --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/destination/DestinationListManagementApiTest.java @@ -0,0 +1,125 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.destination; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DestinationListManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/destination/list"; + + @Test + void getDestinationPage_defaultRequest_returns200_andHasExpectedShape() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .extract() + .response(); + + Integer totalEntries = response.jsonPath().getInt("totalEntries"); + Integer totalPages = response.jsonPath().getInt("totalPages"); + Integer pageNo = response.jsonPath().getInt("pageNo"); + Object entries = response.jsonPath().get("entries"); + + Assertions.assertNotNull(totalEntries); + Assertions.assertTrue(totalEntries >= 0); + + Assertions.assertNotNull(totalPages); + Assertions.assertTrue(totalPages >= 0); + + Assertions.assertNotNull(pageNo); + Assertions.assertTrue(pageNo >= 0); + + Assertions.assertNotNull(entries); + + String etag = response.getHeader("ETag"); + Assertions.assertNotNull(etag); + Assertions.assertFalse(etag.trim().isEmpty()); + } + + @Test + void getDestinationPage_invalidPrefixEncoding_returns400_withStatusResponse() { + Response response = givenAuthenticated() + .queryParam("prefix", "%E0%A4%A") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + Assertions.assertFalse(statusResponse.getStatus().trim().isEmpty()); + } + + @Test + void getDestinationPage_notFoundPrefix_returns404_withStatusResponse() { + String prefix = "it_missing_namespace_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .queryParam("prefix", prefix) + .when() + .get(BASE_PATH) + .then() + .statusCode(404) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + Assertions.assertFalse(statusResponse.getStatus().trim().isEmpty()); + } + + @Test + void getDestinationPage_etagMatch_returns304_noBody() { + Response first = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .extract() + .response(); + + String etag = first.getHeader("ETag"); + Assertions.assertNotNull(etag); + Assertions.assertFalse(etag.trim().isEmpty()); + + Response second = givenAuthenticated() + .header("If-None-Match", etag) + .when() + .get(BASE_PATH) + .then() + .statusCode(304) + .extract() + .response(); + + String body = second.getBody() == null ? "" : second.getBody().asString(); + Assertions.assertTrue(body == null || body.isEmpty()); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreePagingLargeTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreePagingLargeTest.java new file mode 100644 index 000000000..0bff565e9 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreePagingLargeTest.java @@ -0,0 +1,113 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.destination; + + +import io.mapsmessaging.api.DestinationInfo; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.rest.api.impl.destination.context.BrowseEntry; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceTree; +import io.mapsmessaging.rest.api.impl.destination.context.Type; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class NamespaceTreePagingLargeTest { + + @Test + void pagingOverOneThousandEntriesReturnsAllExactlyOnce() { + int totalEntries = 1000; + int pageSize = 37; + + List destinations = new ArrayList<>(totalEntries); + for (int i = 0; i < totalEntries; i++) { + String name = "COM3/1/1/topic-" + String.format("%04d", i); + destinations.add(destination(name, DestinationType.TOPIC)); + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + int entryCount = tree.getEntryCount("COM3/1/1"); + assertEquals(totalEntries, entryCount); + + Set seenNames = new HashSet<>(); + int pageNo = 0; + + while (true) { + List page = tree.listAtNode("COM3/1/1", pageNo, pageSize); + if (page.isEmpty()) { + break; + } + + for (BrowseEntry entry : page) { + assertEquals(Type.TOPIC, entry.getDestinationType()); + assertTrue(entry.getName().startsWith("topic-")); + + boolean added = seenNames.add(entry.getName()); + assertTrue(added, "Duplicate entry detected: " + entry.getName()); + } + + pageNo++; + } + + assertEquals(totalEntries, seenNames.size()); + + assertTrue(seenNames.contains("topic-0000")); + assertTrue(seenNames.contains("topic-0999")); + } + + @Test + void pagingWithPageSizeOneWorks() { + int totalEntries = 1000; + + List destinations = new ArrayList<>(totalEntries); + for (int i = 0; i < totalEntries; i++) { + String name = "COM3/1/1/topic-" + String.format("%04d", i); + destinations.add(destination(name, DestinationType.TOPIC)); + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + Set seenNames = new HashSet<>(); + + for (int pageNo = 0; pageNo < totalEntries; pageNo++) { + List page = tree.listAtNode("COM3/1/1", pageNo, 1); + assertEquals(1, page.size()); + boolean added = seenNames.add(page.get(0).getName()); + assertTrue(added); + } + + List beyond = tree.listAtNode("COM3/1/1", totalEntries, 1); + assertTrue(beyond.isEmpty()); + + assertEquals(totalEntries, seenNames.size()); + } + + private static DestinationInfo destination(String name, DestinationType type) { + return new DestinationInfo(name, type); + } + + +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRealisticAbsoluteHierarchyWalkTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRealisticAbsoluteHierarchyWalkTest.java new file mode 100644 index 000000000..0c269afec --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRealisticAbsoluteHierarchyWalkTest.java @@ -0,0 +1,113 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.destination; + +import io.mapsmessaging.api.DestinationInfo; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.rest.api.impl.destination.context.BrowseEntry; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceNode; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceTree; +import io.mapsmessaging.rest.api.impl.destination.context.Type; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class NamespaceTreeRealisticAbsoluteHierarchyWalkTest { + + @Test + void absoluteHierarchySupportsWalkingAndFindingEveryFolder() { + int rootFolders = 5; + int level1Folders = 4; + int level2Folders = 3; + int destinationsPerLeaf = 30; + + List destinations = new ArrayList<>(); + + for (int r = 1; r <= rootFolders; r++) { + String root = "/root-" + r; + + for (int a = 1; a <= level1Folders; a++) { + String level1 = root + "/group-" + a; + + for (int b = 1; b <= level2Folders; b++) { + String leaf = level1 + "/node-" + b; + + for (int d = 0; d < destinationsPerLeaf; d++) { + DestinationType type = (d % 2 == 0) + ? DestinationType.TOPIC + : DestinationType.QUEUE; + + String name = (type == DestinationType.TOPIC ? "topic-" : "queue-") + + String.format("%02d", d); + + destinations.add(destination(leaf + "/" + name, type)); + } + } + } + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + NamespaceNode absoluteRootMarker = tree.findNode("/"); + assertNotNull(absoluteRootMarker); + assertEquals("/", absoluteRootMarker.getFullPath()); + + for (int r = 1; r <= rootFolders; r++) { + String rootPath = "/root-" + r; + + NamespaceNode rootNode = tree.findNode(rootPath); + assertNotNull(rootNode, "Missing root folder: " + rootPath); + assertEquals(rootPath, rootNode.getFullPath()); + + for (int a = 1; a <= level1Folders; a++) { + String groupPath = rootPath + "/group-" + a; + + NamespaceNode groupNode = tree.findNode(groupPath); + assertNotNull(groupNode, "Missing group folder: " + groupPath); + assertEquals(groupPath, groupNode.getFullPath()); + + for (int b = 1; b <= level2Folders; b++) { + String nodePath = groupPath + "/node-" + b; + + NamespaceNode leafNode = tree.findNode(nodePath); + assertNotNull(leafNode, "Missing leaf folder: " + nodePath); + assertEquals(nodePath, leafNode.getFullPath()); + + List leafEntries = tree.listAtNode(nodePath, 0, 100); + assertEquals(destinationsPerLeaf, leafEntries.size(), "Unexpected leaf entry count at " + nodePath); + + for (BrowseEntry entry : leafEntries) { + assertNotNull(entry.getName()); + assertNotNull(entry.getFullPath()); + assertTrue(entry.getFullPath().startsWith(nodePath + "/")); + assertTrue(entry.getDestinationType() == Type.TOPIC || entry.getDestinationType() == Type.QUEUE); + } + } + } + } + } + + private static DestinationInfo destination(String name, DestinationType type) { + return new DestinationInfo(name, type); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRestPagingSemanticsTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRestPagingSemanticsTest.java new file mode 100644 index 000000000..bb95cc0aa --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeRestPagingSemanticsTest.java @@ -0,0 +1,151 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.destination; + + +import io.mapsmessaging.api.DestinationInfo; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.rest.api.impl.destination.context.BrowseEntry; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceTree; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class NamespaceTreeRestPagingSemanticsTest { + + @Test + void restPagingClampAllowsForwardBackwardAndCorrectSlice() { + int totalEntries = 1000; + + List destinations = new ArrayList<>(totalEntries); + for (int i = 0; i < totalEntries; i++) { + destinations.add(destination("COM3/1/1/topic-" + String.format("%04d", i), DestinationType.TOPIC)); + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + int requestedPageSize = 1; + int effectivePageSize = clampRestPageSize(requestedPageSize); + + int pageNo = 5; + + List page5 = tree.listAtNode("COM3/1/1", pageNo, effectivePageSize); + assertEquals(effectivePageSize, page5.size()); + assertEquals("topic-0050", page5.get(0).getName()); + assertEquals("topic-0059", page5.get(page5.size() - 1).getName()); + + List page6 = tree.listAtNode("COM3/1/1", pageNo + 1, effectivePageSize); + assertEquals(effectivePageSize, page6.size()); + assertEquals("topic-0060", page6.get(0).getName()); + assertEquals("topic-0069", page6.get(page6.size() - 1).getName()); + + List backTo5 = tree.listAtNode("COM3/1/1", pageNo, effectivePageSize); + assertEquals(page5.size(), backTo5.size()); + assertEquals(page5.get(0).getName(), backTo5.get(0).getName()); + assertEquals(page5.get(page5.size() - 1).getName(), backTo5.get(backTo5.size() - 1).getName()); + + List page4 = tree.listAtNode("COM3/1/1", pageNo - 1, effectivePageSize); + assertEquals(effectivePageSize, page4.size()); + assertEquals("topic-0040", page4.get(0).getName()); + assertEquals("topic-0049", page4.get(page4.size() - 1).getName()); + } + + @Test + void restPagingClampStillReturnsAllEntriesExactlyOnce() { + int totalEntries = 1000; + + List destinations = new ArrayList<>(totalEntries); + for (int i = 0; i < totalEntries; i++) { + destinations.add(destination("COM3/1/1/topic-" + String.format("%04d", i), DestinationType.TOPIC)); + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + int requestedPageSize = 3; + int effectivePageSize = clampRestPageSize(requestedPageSize); + + Set seen = new HashSet<>(); + int pageNo = 0; + + while (true) { + List page = tree.listAtNode("COM3/1/1", pageNo, effectivePageSize); + if (page.isEmpty()) { + break; + } + + for (BrowseEntry entry : page) { + boolean added = seen.add(entry.getName()); + assertTrue(added, "Duplicate entry: " + entry.getName()); + } + + pageNo++; + } + + assertEquals(totalEntries, seen.size()); + assertTrue(seen.contains("topic-0000")); + assertTrue(seen.contains("topic-0999")); + } + + @Test + void changingEffectivePageSizeChangesSliceAsExpected() { + int totalEntries = 1000; + + List destinations = new ArrayList<>(totalEntries); + for (int i = 0; i < totalEntries; i++) { + destinations.add(destination("COM3/1/1/topic-" + String.format("%04d", i), DestinationType.TOPIC)); + } + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + int pageNo = 7; + + int effectivePageSize10 = clampRestPageSize(10); + List pageNo7Size10 = tree.listAtNode("COM3/1/1", pageNo, effectivePageSize10); + assertEquals(10, pageNo7Size10.size()); + assertEquals("topic-0070", pageNo7Size10.get(0).getName()); + assertEquals("topic-0079", pageNo7Size10.get(9).getName()); + + int effectivePageSize100 = clampRestPageSize(100); + List pageNo7Size100 = tree.listAtNode("COM3/1/1", pageNo, effectivePageSize100); + assertEquals(100, pageNo7Size100.size()); + assertEquals("topic-0700", pageNo7Size100.get(0).getName()); + assertEquals("topic-0799", pageNo7Size100.get(99).getName()); + } + + private static int clampRestPageSize(int requestedPageSize) { + int pageSize = requestedPageSize; + if (pageSize <= 10) { + pageSize = 10; + } + if (pageSize > 1000) { + pageSize = 1000; + } + return pageSize; + } + + private static DestinationInfo destination(String name, DestinationType type) { + return new DestinationInfo(name, type); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeTest.java new file mode 100644 index 000000000..60e66ad25 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/destination/NamespaceTreeTest.java @@ -0,0 +1,225 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.destination; + +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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. + */ + +import io.mapsmessaging.api.DestinationInfo; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.rest.api.impl.destination.context.BrowseEntry; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceNode; +import io.mapsmessaging.rest.api.impl.destination.context.NamespaceTree; +import io.mapsmessaging.rest.api.impl.destination.context.Type; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class NamespaceTreeTest { + + @Test + void relativeAndAbsoluteNamespacesAreDifferent() { + List destinations = List.of( + destination("COM3/1/1/topicA", DestinationType.TOPIC), + destination("/COM3/1/1/topicB", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + NamespaceNode relativeCom3 = tree.findNode("COM3"); + assertNotNull(relativeCom3); + assertEquals("COM3", relativeCom3.getSegment()); + assertEquals("COM3", relativeCom3.getFullPath()); + + NamespaceNode absoluteCom3 = tree.findNode("/COM3"); + assertNotNull(absoluteCom3); + assertEquals("COM3", absoluteCom3.getSegment()); + assertEquals("/COM3", absoluteCom3.getFullPath()); + + assertNotSame(relativeCom3, absoluteCom3); + } + + @Test + void findNodeReturnsNullWhenOnlyRelativeExistsButAbsoluteRequested() { + List destinations = List.of( + destination("COM3/1/1/topicA", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + assertNotNull(tree.findNode("COM3")); + assertNull(tree.findNode("/COM3")); + } + + @Test + void findNodeReturnsNullWhenOnlyAbsoluteExistsButRelativeRequested() { + List destinations = List.of( + destination("/COM3/1/1/topicA", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + assertNotNull(tree.findNode("/COM3")); + assertNull(tree.findNode("COM3")); + } + + @Test + void listingAtCom3ShowsChildFolderOneForBothNamespacesIndependently() { + List destinations = List.of( + destination("COM3/1/1/topicA", DestinationType.TOPIC), + destination("/COM3/1/1/topicB", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + List relative = tree.listAtNode("COM3", 0, 100); + assertEquals(1, relative.size()); + assertEquals("1", relative.get(0).getName()); + assertEquals("COM3/1", relative.get(0).getFullPath()); + assertEquals(Type.FOLDER, relative.get(0).getDestinationType()); + assertTrue(relative.get(0).getChildCount() > 0); + + List absolute = tree.listAtNode("/COM3", 0, 100); + assertEquals(1, absolute.size()); + assertEquals("1", absolute.get(0).getName()); + assertEquals("/COM3/1", absolute.get(0).getFullPath()); + assertEquals(Type.FOLDER, absolute.get(0).getDestinationType()); + assertTrue(absolute.get(0).getChildCount() > 0); + } + + @Test + void listingAtLeafFolderShowsDestinations() { + List destinations = List.of( + destination("COM3/1/1/topicA", DestinationType.TOPIC), + destination("COM3/1/1/queueA", DestinationType.QUEUE), + destination("/COM3/1/1/topicB", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + List relativeLeaf = tree.listAtNode("COM3/1/1", 0, 100); + assertTrue(relativeLeaf.stream().anyMatch(e -> e.getName().equals("topicA") && e.getDestinationType() == Type.TOPIC)); + assertTrue(relativeLeaf.stream().anyMatch(e -> e.getName().equals("queueA") && e.getDestinationType() == Type.QUEUE)); + + List absoluteLeaf = tree.listAtNode("/COM3/1/1", 0, 100); + assertEquals(1, absoluteLeaf.size()); + assertEquals("topicB", absoluteLeaf.get(0).getName()); + assertEquals("/COM3/1/1/topicB", absoluteLeaf.get(0).getFullPath()); + assertEquals(Type.TOPIC, absoluteLeaf.get(0).getDestinationType()); + } + + @Test + void paginationWorksOnAStableSortedList() { + List destinations = List.of( + destination("COM3/1/1/b", DestinationType.TOPIC), + destination("COM3/1/1/a", DestinationType.TOPIC), + destination("COM3/1/1/c", DestinationType.TOPIC), + destination("COM3/1/1/d", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + List page0 = tree.listAtNode("COM3/1/1", 0, 2); + assertEquals(2, page0.size()); + assertEquals("a", page0.get(0).getName()); + assertEquals("b", page0.get(1).getName()); + + List page1 = tree.listAtNode("COM3/1/1", 1, 2); + assertEquals(2, page1.size()); + assertEquals("c", page1.get(0).getName()); + assertEquals("d", page1.get(1).getName()); + + List page2 = tree.listAtNode("COM3/1/1", 2, 2); + assertTrue(page2.isEmpty()); + } + + + @Test + void findNodeForAbsoluteFirstSegmentMustWork() { + List destinations = List.of( + destination("/COM3/1/1/HEARTBEAT", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + NamespaceNode root = tree.getRoot(); + assertNotNull(root); + + NamespaceNode absoluteMarker = root.getChild(""); + assertNotNull(absoluteMarker, "Root must have an absolute marker child with segment \"\""); + + NamespaceNode slash = tree.findNode("/"); + assertNotNull(slash, "findNode(\"/\") must not be null"); + assertEquals("/", slash.getFullPath(), "findNode(\"/\") must return the absolute marker node, not root"); + + NamespaceNode com3 = tree.findNode("/COM3"); + assertNotNull(com3, "findNode(\"/COM3\") must resolve when /COM3/... exists"); + assertEquals("/COM3", com3.getFullPath()); + } + + + @Test + void rootIsDistinctForAbsoluteMarkerPath() { + List destinations = List.of( + destination("/COM3/1/1/topicB", DestinationType.TOPIC), + destination("COM3/1/1/topicA", DestinationType.TOPIC) + ); + + NamespaceTree tree = NamespaceTree.buildFromPaths(destinations); + + NamespaceNode root = tree.getRoot(); + assertNotNull(root); + assertEquals("", root.getFullPath()); + + NamespaceNode absoluteMarker = tree.findNode("/"); + assertNotNull(absoluteMarker); + assertEquals("/", absoluteMarker.getFullPath()); + + List rootEntries = tree.listAtNode("", 0, 100); + assertTrue(rootEntries.stream().anyMatch(e -> e.getName().equals("COM3") && e.getFullPath().equals("COM3"))); + assertTrue(rootEntries.stream().anyMatch(e -> e.getName().equals("") && e.getFullPath().equals("/"))); + + List absoluteRootEntries = tree.listAtNode("/", 0, 100); + assertTrue(absoluteRootEntries.stream().anyMatch(e -> e.getName().equals("COM3") && e.getFullPath().equals("/COM3"))); + } + + private static DestinationInfo destination(String name, DestinationType type) { + return new DestinationInfo(name, type); + } + +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/discovery/DiscoveryManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/discovery/DiscoveryManagementApiTest.java new file mode 100644 index 000000000..6ce2d0bf4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/discovery/DiscoveryManagementApiTest.java @@ -0,0 +1,163 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.discovery; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DiscoveryManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/discovery"; + + @Test + void patchStartDiscoveryManager_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"started\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchDiscoveryManager_blankState_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchDiscoveryManager_unknownState_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"banana\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getDiscoveredServers_noFilter_returns200() { + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void getDiscoveredServers_invalidFilter_returns400() { + Response response = givenAuthenticated() + .queryParam("filter", "this is not a valid selector !!!") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getDiscoveredServers_validFilter_returns200() { + givenAuthenticated() + .queryParam("filter", "true") + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON); + } + + @Test + void getDiscoveredServers_anonymous_returns401_withoutOpenApiValidation() { + Response response = givenAnonymousNoValidation() + .when() + .get(BASE_PATH) + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchDiscoveryManager_anonymous_returns401_withoutOpenApiValidation() { + Response response = givenAnonymousNoValidation() + .contentType(ContentType.JSON) + .body("{\"state\":\"started\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private RequestSpecification givenAnonymousNoValidation() { + return io.restassured.RestAssured + .given() + .baseUri(baseUrl); + } + + private boolean hasNonBlankStatusMessage(Response response) { + JsonPath path = response.jsonPath(); + String status = null; + if(path != null) { + status = path.getString("status"); + } + return status != null && !status.isBlank(); + + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/hardware/HardwareManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/hardware/HardwareManagementApiTest.java new file mode 100644 index 000000000..64f4a5bc2 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/hardware/HardwareManagementApiTest.java @@ -0,0 +1,105 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.hardware; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class HardwareManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/hardware"; + + @Test + void scanForDevices_returns200_andJsonArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .post(BASE_PATH + "/scan") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object payload = response.jsonPath().get("$"); + Assertions.assertNotNull(payload); + } + + @Test + void getAllDiscoveredDevices_returns200_andJsonArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object payload = response.jsonPath().get("$"); + Assertions.assertNotNull(payload); + } + + @Test + void getAllDiscoveredDevices_anonymous_returns401_withoutOpenApiValidation() { + Response response = givenAnonymousNoValidation() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void scanForDevices_anonymous_returns401_withoutOpenApiValidation() { + Response response = givenAnonymousNoValidation() + .contentType(ContentType.JSON) + .when() + .post(BASE_PATH + "/scan") + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private RequestSpecification givenAnonymousNoValidation() { + return io.restassured.RestAssured + .given() + .baseUri(baseUrl); + } + + private boolean hasNonBlankStatusMessage(Response response) { + String status = response.jsonPath().getString("status"); + return status != null && !status.isBlank(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationInstanceManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationInstanceManagementApiTest.java new file mode 100644 index 000000000..d3e7d54d4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationInstanceManagementApiTest.java @@ -0,0 +1,249 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.integration; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class IntegrationInstanceManagementApiTest extends ApiTestBase { + + private static final String LIST_BASE_PATH = "/api/v1/server/integrations"; + private static final String INSTANCE_BASE_PATH = "/api/v1/server/integration"; + + @Test + void getIntegrationByName_returns200_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName)) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.jsonPath().get("$")); + } + + @Test + void getIntegrationByName_returns404_forUnknownName() { + String unknownName = "it_unknown_integration_404"; + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(unknownName)) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getIntegrationConnection_returns200_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName) + "/connection") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.jsonPath().get("$")); + } + + @Test + void getIntegrationStatus_returns200_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName) + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.jsonPath().get("$")); + } + + @Test + void patchIntegrationAction_started_returns200_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"started\"}") + .when() + .patch(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName)) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchIntegrationAction_blankState_returns400_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"\"}") + .when() + .patch(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName)) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchIntegrationAction_unknownState_returns400_forNameFromList() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"banana\"}") + .when() + .patch(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName)) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getIntegrationByName_anonymous_returns401_withoutOpenApiValidation() { + String integrationName = resolveFirstIntegrationNameOrSkip(); + if (integrationName == null) { + return; + } + + Response response = givenAnonymousNoValidation() + .contentType(ContentType.JSON) + .when() + .get(INSTANCE_BASE_PATH + "/" + urlEncodePathSegment(integrationName)) + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private String resolveFirstIntegrationNameOrSkip() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(LIST_BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + JsonPath jsonPath = response.jsonPath(); + + Object listObject = jsonPath.get("protocols"); + if (!(listObject instanceof List list)) { + return null; + } + + for (Object entry : list) { + if (!(entry instanceof java.util.Map map)) { + continue; + } + + Object nameValue = map.get("name"); + if (nameValue instanceof String name && !name.isBlank()) { + return name; + } + + Object configNameValue = map.get("configName"); + if (configNameValue instanceof String configName && !configName.isBlank()) { + return configName; + } + } + + return null; + } + + private RequestSpecification givenAnonymousNoValidation() { + return RestAssured + .given() + .baseUri(baseUrl); + } + + private String urlEncodePathSegment(String value) { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } + + private boolean hasNonBlankStatusMessage(Response response) { + String status = response.jsonPath().getString("status"); + return status != null && !status.isBlank(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationManagementApiTest.java new file mode 100644 index 000000000..27ceb6ad0 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/integration/IntegrationManagementApiTest.java @@ -0,0 +1,209 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.integration; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class IntegrationManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/integrations"; + + @Test + void getAllIntegrations_returns200_andHasProtocolsArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object protocols = response.jsonPath().get("protocols"); + Assertions.assertNotNull(protocols); + } + + @Test + void getAllIntegrations_withValidFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "true") + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object protocols = response.jsonPath().get("protocols"); + Assertions.assertNotNull(protocols); + } + + @Test + void getAllIntegrations_withInvalidFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "this is not valid !!!") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchAllIntegrations_started_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"started\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchAllIntegrations_blankState_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchAllIntegrations_unknownState_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"banana\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllIntegrationStatus_returns200_andIsArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object raw = response.jsonPath().get("$"); + Assertions.assertNotNull(raw); + Assertions.assertTrue(raw instanceof List); + } + + @Test + void getAllIntegrationStatus_withValidFilter_returns200_andIsArray() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "true") + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Object raw = response.jsonPath().get("$"); + Assertions.assertNotNull(raw); + Assertions.assertTrue(raw instanceof List); + } + + @Test + void getAllIntegrationStatus_withInvalidFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "this is not valid !!!") + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllIntegrations_anonymous_returns401_withoutOpenApiValidation() { + Response response = givenAnonymousNoValidation() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(401) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private RequestSpecification givenAnonymousNoValidation() { + return RestAssured + .given() + .baseUri(baseUrl); + } + + private boolean hasNonBlankStatusMessage(Response response) { + String status = response.jsonPath().getString("status"); + return status != null && !status.isBlank(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceInstanceApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceInstanceApiTest.java new file mode 100644 index 000000000..1e3313654 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceInstanceApiTest.java @@ -0,0 +1,302 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.interfaces; + +import io.mapsmessaging.MessageDaemon; +import io.mapsmessaging.network.EndPointManager; +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +class InterfaceInstanceApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/interface"; + + @Test + @Disabled("Not yet implemented") + void getEndPoint_existingEndpoint_returns200() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + Assertions.assertFalse(response.asString().trim().isEmpty()); + } + + @Test + void getEndPoint_invalidEndpointId_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/not-a-uuid") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getEndPoint_notFound_returns404() { + UUID endpointId = UUID.randomUUID(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getConnections_existingEndpoint_returns200() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId + "/connections") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + @Test + void getConnections_invalidEndpointId_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/not-a-uuid/connections") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getConnections_notFound_returns404() { + UUID endpointId = UUID.randomUUID(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId + "/connections") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getStatus_existingEndpoint_returns200() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + @Test + void getStatus_invalidEndpointId_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/not-a-uuid/status") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getStatus_notFound_returns404() { + UUID endpointId = UUID.randomUUID(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + endpointId + "/status") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchControl_blankState_returns400() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"\"}") + .when() + .patch(BASE_PATH + "/" + endpointId) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchControl_unknownState_returns400() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"teleport\"}") + .when() + .patch(BASE_PATH + "/" + endpointId) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchControl_notFound_returns404() { + UUID endpointId = UUID.randomUUID(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"paused\"}") + .when() + .patch(BASE_PATH + "/" + endpointId) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchControl_validState_returns200() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"started\"}") + .when() + .patch(BASE_PATH + "/" + endpointId) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void putUpdate_nullBody_returns400_withoutOpenApiValidation() { + UUID endpointId = getExistingEndpointId(); + + Response response = givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .put(BASE_PATH + "/" + endpointId) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private UUID getExistingEndpointId() { + List endPointManagers = MessageDaemon.getInstance() + .getSubSystemManager() + .getNetworkManager() + .getAll(); + + Assertions.assertNotNull(endPointManagers); + Assertions.assertFalse(endPointManagers.isEmpty()); + + UUID endpointId = endPointManagers.get(0).getUniqueId(); + Assertions.assertNotNull(endpointId); + return endpointId; + } + + private boolean hasNonBlankStatusMessage(Response response) { + StatusResponse statusResponse = response.as(StatusResponse.class); + if (statusResponse == null) { + return false; + } + if (statusResponse.getStatus() == null) { + return false; + } + return !statusResponse.getStatus().trim().isEmpty(); + } + +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceManagementApiTest.java new file mode 100644 index 000000000..62a65cdfd --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/interfaces/InterfaceManagementApiTest.java @@ -0,0 +1,218 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.interfaces; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class InterfaceManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/interfaces"; + + @Disabled("Not yet implemented") + @Test + void getAllInterfaces_noFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + @Test + void getAllInterfaces_blankFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", " ") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllInterfaces_invalidFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "this is not valid selector syntax") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllInterfaces_validFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "state = 'started'") + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + @Test + void patchManageAll_nullBody_returns400_withoutOpenApiValidation() { + Response response = givenAuthenticatedNoValidation() + .contentType(ContentType.JSON) + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchManageAll_blankState_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void patchManageAll_unknownAction_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body("{\"state\":\"teleport\"}") + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllStatus_noFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + @Test + void getAllStatus_blankFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", " ") + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllStatus_invalidFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "this is not valid selector syntax") + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getAllStatus_validFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "state = 'started'") + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + } + + private boolean hasNonBlankStatusMessage(Response response) { + StatusResponse statusResponse = response.as(StatusResponse.class); + if (statusResponse == null) { + return false; + } + if (statusResponse.getStatus() == null) { + return false; + } + return !statusResponse.getStatus().trim().isEmpty(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/logging/LogMonitorRestApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/logging/LogMonitorRestApiTest.java new file mode 100644 index 000000000..1e1b6de14 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/logging/LogMonitorRestApiTest.java @@ -0,0 +1,107 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.logging; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LogMonitorRestApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/log"; + + @Test + void getLogEntries_noFilter_returns200() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertNotNull(response.asString()); + Assertions.assertFalse(response.asString().trim().isEmpty()); + } + + @Test + void getLogEntries_blankFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", " ") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getLogEntries_invalidFilter_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .queryParam("filter", "this is not valid selector syntax") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void requestSseToken_returns200_andNonBlankToken() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/sse") + .then() + .statusCode(200) + .extract() + .response(); + + String token = response.asString(); + Assertions.assertNotNull(token); + Assertions.assertFalse(token.trim().isEmpty()); + } + + + private boolean hasNonBlankStatusMessage(Response response) { + StatusResponse statusResponse = response.as(StatusResponse.class); + if (statusResponse == null) { + return false; + } + if (statusResponse.getStatus() == null) { + return false; + } + return !statusResponse.getStatus().trim().isEmpty(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/lora/LoRaDeviceApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/lora/LoRaDeviceApiTest.java new file mode 100644 index 000000000..1bcf515c4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/lora/LoRaDeviceApiTest.java @@ -0,0 +1,148 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.lora; + +import io.mapsmessaging.dto.rest.lora.LoRaDeviceInfoDTO; +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LoRaDeviceApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/device/lora"; + + @Test + void getAllLoRaDevices_returns200_andJson() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + LoRaDeviceInfoDTO[] devices = response.as(LoRaDeviceInfoDTO[].class); + Assertions.assertNotNull(devices); + } + + @Test + void getLoRaDevice_unknownDevice_returns404() { + String unknownDeviceName = "it_unknown_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + unknownDeviceName) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getLoRaDevice_existingDevice_returns200_whenAvailable() { + Response listResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + LoRaDeviceInfoDTO[] devices = listResponse.as(LoRaDeviceInfoDTO[].class); + Assertions.assertNotNull(devices); + + Assumptions.assumeTrue(devices.length > 0, "No LoRa devices configured in this environment"); + + String deviceName = devices[0].getName(); + Assertions.assertNotNull(deviceName); + Assertions.assertFalse(deviceName.trim().isEmpty()); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + deviceName) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + LoRaDeviceInfoDTO device = response.as(LoRaDeviceInfoDTO.class); + Assertions.assertNotNull(device); + Assertions.assertNotNull(device.getName()); + } + + @Test + void getLoRaEndPointConnections_invalidNodeId_returns400() { + String unknownDeviceName = "it_unknown_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + unknownDeviceName + "/not-an-int") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void getLoRaEndPointConnections_unknownDevice_returns404() { + String unknownDeviceName = "it_unknown_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .when() + .get(BASE_PATH + "/" + unknownDeviceName + "/1") + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private boolean hasNonBlankStatusMessage(Response response) { + StatusResponse statusResponse = response.as(StatusResponse.class); + if (statusResponse == null) { + return false; + } + if (statusResponse.getStatus() == null) { + return false; + } + return !statusResponse.getStatus().trim().isEmpty(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/messaging/MessagingApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/messaging/MessagingApiTest.java new file mode 100644 index 000000000..184f141cd --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/messaging/MessagingApiTest.java @@ -0,0 +1,225 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.messaging; + +import io.mapsmessaging.dto.rest.messaging.ConsumeRequestDTO; +import io.mapsmessaging.dto.rest.messaging.MessageDTO; +import io.mapsmessaging.dto.rest.messaging.PublishRequestDTO; +import io.mapsmessaging.dto.rest.messaging.SubscriptionRequestDTO; +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.ConsumedResponse; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.mapsmessaging.rest.responses.SubscriptionDepthResponse; +import io.mapsmessaging.rest.responses.TransactionData; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.List; + +public class MessagingApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/messaging"; + + @Test + void publish_subscribe_consume_unsubscribe_happyPath() { + String topic = "/it_messaging_" + System.currentTimeMillis(); + MessageDTO message = new MessageDTO(); + message.setPayload(Base64.getEncoder().encodeToString ("hello".getBytes())); + Response publishResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildPublishRequest(topic, message)) + .when() + .post(BASE_PATH + "/publish") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(publishResponse)); + + Response subscribeResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildSubscriptionRequest(topic)) + .when() + .post(BASE_PATH + "/subscribe") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(subscribeResponse)); + + Response consumeResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildConsumeRequest(topic, 10)) + .when() + .post(BASE_PATH + "/consume") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + ConsumedResponse consumedResponse = consumeResponse.as(ConsumedResponse.class); + Assertions.assertNotNull(consumedResponse); + + Response depthResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildConsumeRequest(topic, 10)) + .when() + .post(BASE_PATH + "/subscriptionDepth") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + SubscriptionDepthResponse subscriptionDepthResponse = depthResponse.as(SubscriptionDepthResponse.class); + Assertions.assertNotNull(subscriptionDepthResponse); + + Response unsubscribeResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildSubscriptionRequest(topic)) + .when() + .post(BASE_PATH + "/unsubscribe") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(unsubscribeResponse)); + } + + @Test + void publish_blankDestination_returns400() { + MessageDTO message = new MessageDTO(); + message.setPayload("hello"); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildPublishRequest(" ", message)) + .when() + .post(BASE_PATH + "/publish") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void subscribe_blankDestination_returns400() { + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(buildSubscriptionRequest(" ")) + .when() + .post(BASE_PATH + "/subscribe") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void commit_emptyEventIds_returns400() { + TransactionData transactionData = new TransactionData(); + transactionData.setDestinationName("/it_messaging_" + System.currentTimeMillis()); + transactionData.setEventIds(List.of()); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(transactionData) + .when() + .post(BASE_PATH + "/commit") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + @Test + void abort_emptyEventIds_returns400() { + TransactionData transactionData = new TransactionData(); + transactionData.setDestinationName("/it_messaging_" + System.currentTimeMillis()); + transactionData.setEventIds(List.of()); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(transactionData) + .when() + .post(BASE_PATH + "/abort") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .extract() + .response(); + + Assertions.assertTrue(hasNonBlankStatusMessage(response)); + } + + private Object buildPublishRequest(String destinationName, MessageDTO message) { + PublishRequestDTO publishRequestDTO = new PublishRequestDTO(); + publishRequestDTO.setDestinationName(destinationName); + publishRequestDTO.setMessage(message); + return publishRequestDTO; + } + + private Object buildSubscriptionRequest(String destinationName) { + SubscriptionRequestDTO subscriptionRequestDTO = new SubscriptionRequestDTO(); + subscriptionRequestDTO.setDestinationName(destinationName); + subscriptionRequestDTO.setTransactional(false); + subscriptionRequestDTO.setMaxDepth(100); + subscriptionRequestDTO.setNamedSubscription(null); + subscriptionRequestDTO.setFilter(null); + return subscriptionRequestDTO; + } + + private Object buildConsumeRequest(String destinationName, int depth) { + ConsumeRequestDTO consumeRequestDTO = new ConsumeRequestDTO(); + consumeRequestDTO.setDestination(destinationName); + consumeRequestDTO.setDepth(depth); + return consumeRequestDTO; + } + + private boolean hasNonBlankStatusMessage(Response response) { + StatusResponse statusResponse = response.as(StatusResponse.class); + if (statusResponse == null) { + return false; + } + if (statusResponse.getStatus() == null) { + return false; + } + return !statusResponse.getStatus().trim().isEmpty(); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/ml/ModelStoreApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/ml/ModelStoreApiTest.java new file mode 100644 index 000000000..bb426cceb --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/ml/ModelStoreApiTest.java @@ -0,0 +1,219 @@ +package io.mapsmessaging.rest.api.impl.ml; + +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +public class ModelStoreApiTest extends ApiTestBase { + + private static final String BASE_PATH = "/api/v1/server/models"; + + @Test + void listModels_whenMlNotSupported_returns406WithJsonBody() { + Response response = + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(anyOf(is(200), is(406))) + .extract() + .response(); + + if (response.statusCode() == 406) { + response.then() + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + } + + @Test + void uploadModel_blankName_returns400() { + givenAuthenticated() + .contentType("multipart/form-data") + .multiPart("file", "model.bin", "abc".getBytes(StandardCharsets.UTF_8), "application/octet-stream") + .when() + .post(BASE_PATH + "/ ") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void uploadModel_missingFile_returns400() { + String modelName = "it_" + Instant.now().toEpochMilli() + "_nofile"; + + assumeMlSupported(); + + givenAuthenticated() + .contentType("multipart/form-data") + .when() + .post(BASE_PATH + "/" + modelName) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void getModel_blankName_returns400() { + givenAuthenticated() + .when() + .get(BASE_PATH + "/ ") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void deleteModel_blankName_returns400() { + givenAuthenticated() + .when() + .delete(BASE_PATH + "/ ") + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void modelLifecycle_upload_get_head_list_delete_happyPath() { + assumeMlSupported(); + + String modelName = "it_" + Instant.now().toEpochMilli() + "_model.bin"; + byte[] payload = ("hello-" + modelName).getBytes(StandardCharsets.UTF_8); + + // upload + givenAuthenticated() + .contentType("multipart/form-data") + .multiPart("file", modelName, payload, "application/octet-stream") + .when() + .post(BASE_PATH + "/" + modelName) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + + // head exists + givenAuthenticated() + .when() + .head(BASE_PATH + "/" + modelName) + .then() + .statusCode(200); + + // list contains + Response listResponse = + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .response(); + + String[] modelNames = listResponse.as(String[].class); + assertThat(modelNames, hasItemInArray(modelName)); + + // download + Response downloadResponse = + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + modelName) + .then() + .statusCode(200) + .extract() + .response(); + + assertThat(downloadResponse.contentType(), startsWith("application/octet-stream")); + assertThat(downloadResponse.getHeader("Content-Disposition"), containsString(modelName)); + byte[] downloaded = downloadResponse.asByteArray(); + assertThat(downloaded, equalTo(payload)); + + // delete (safe) + deleteIfSafe(modelName); + + // head not found + givenAuthenticated() + .when() + .head(BASE_PATH + "/" + modelName) + .then() + .statusCode(404); + + // get not found (json) + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + modelName) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void getModel_nonExistent_returns404() { + assumeMlSupported(); + + String modelName = "it_" + Instant.now().toEpochMilli() + "_missing.bin"; + + givenAuthenticated() + .when() + .get(BASE_PATH + "/" + modelName) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + @Test + void deleteModel_nonExistent_returns404() { + assumeMlSupported(); + + String modelName = "it_" + Instant.now().toEpochMilli() + "_missing.bin"; + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + modelName) + .then() + .statusCode(404) + .contentType(ContentType.JSON) + .body("status", not(isEmptyOrNullString())); + } + + private void deleteIfSafe(String modelName) { + if (modelName == null || !modelName.startsWith("it_")) { + throw new IllegalArgumentException("Refusing to delete non-test model: " + modelName); + } + + givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + modelName) + .then() + .statusCode(anyOf(is(200), is(404))); + } + + private void assumeMlSupported() { + Response response = + givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(anyOf(is(200), is(406))) + .extract() + .response(); + + Assumptions.assumeTrue( + response.statusCode() == 200, + "ML not supported in this environment (GET " + BASE_PATH + " returned 406)" + ); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/schema/SchemaQueryApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/schema/SchemaQueryApiTest.java new file mode 100644 index 000000000..35e158551 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/schema/SchemaQueryApiTest.java @@ -0,0 +1,332 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.schema; + +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.SchemaMapResponse; +import io.mapsmessaging.rest.responses.SchemaPostDTO; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.mapsmessaging.rest.responses.StringListResponse; +import io.mapsmessaging.schemas.config.SchemaConfig; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; +import static org.hamcrest.Matchers.notNullValue; + +public class SchemaQueryApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server/schemas"; + + @Test + void getKnownFormats_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/formats") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + StringListResponse stringListResponse = response.as(StringListResponse.class); + Assertions.assertNotNull(stringListResponse); + } + + @Test + void getLinkFormat_returns200_textPlain() { + String body = givenAuthenticated() + .when() + .get(BASE_PATH + "/link-format") + .then() + .statusCode(200) + .extract() + .asString(); + + Assertions.assertNotNull(body); + } + + @Test + void getAllSchemas_returns200_andHasArrayBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + SchemaConfig[] schemaConfigs = response.as(SchemaConfig[].class); + Assertions.assertNotNull(schemaConfigs); + } + + @Test + void getSchemaMapping_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/map") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + SchemaMapResponse schemaMapResponse = response.as(SchemaMapResponse.class); + Assertions.assertNotNull(schemaMapResponse); + } + + @Test + void getSchemaById_notFound_returns404_withStatusResponse() { + String schemaId = "it_missing_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/" + schemaId) + .then() + .statusCode(404) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void deleteSchemaById_notFound_returns404_withStatusResponse() { + String schemaId = "it_missing_" + System.currentTimeMillis(); + + Response response = givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + schemaId) + .then() + .statusCode(404) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void getSchemaImplById_notFound_returns404_noBody() { + String schemaId = "it_missing_" + System.currentTimeMillis(); + + givenAuthenticated() + .when() + .get(BASE_PATH + "/impl/" + schemaId) + .then() + .statusCode(404); + } + + @Test + void getAllSchemas_invalidFilter_returns400_withStatusResponse() { + Response response = givenAuthenticated() + .queryParam("filter", "this is not a selector expression {") + .when() + .get(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void deleteAllSchemas_invalidFilter_returns400_withStatusResponse() { + Response response = givenAuthenticated() + .queryParam("filter", "this is not a selector expression {") + .when() + .delete(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void addGetDeleteSchema_happyPath_usingTemplateSchemaPack() { + String context = "it_ctx_" + System.currentTimeMillis(); + + SchemaConfig templateSchemaConfig = getAnyExistingSchemaConfig(); + if (templateSchemaConfig == null) { + Assertions.fail("No existing schema available to use as a template for integration tests"); + return; + } + + String templateSchemaId = templateSchemaConfig.getUniqueId(); + Assertions.assertNotNull(templateSchemaId); + + String packedTemplate = givenAuthenticated() + .when() + .get(BASE_PATH + "/" + templateSchemaId) + .then() + .statusCode(200) + .extract() + .asString(); + + String newSchemaId = UUID.randomUUID().toString(); + String packedMutated = mutateSchemaIdInPackedSchema(packedTemplate, templateSchemaId, newSchemaId); + + SchemaPostDTO schemaPostDTO = new SchemaPostDTO(); + schemaPostDTO.setContext(context); + schemaPostDTO.setSchema(packedMutated); + + String createResponse = givenAuthenticated() + .contentType(ContentType.JSON) + .body(schemaPostDTO) + .when() + .post(BASE_PATH) + .then() + .statusCode(200) + .extract() + .asString(); + + Assertions.assertNotNull(createResponse); + + String getResponse = givenAuthenticated() + .when() + .get(BASE_PATH + "/" + newSchemaId) + .then() + .statusCode(200) + .extract() + .asString(); + + Assertions.assertNotNull(getResponse); + + assertSafeToDeleteSchemaId(newSchemaId); + + String deleteResponse = givenAuthenticated() + .when() + .delete(BASE_PATH + "/" + newSchemaId) + .then() + .statusCode(200) + .extract() + .asString(); + + Assertions.assertNotNull(deleteResponse); + } + + @Test + void addSchema_blankContext_returns400_withStatusResponse() { + SchemaPostDTO schemaPostDTO = new SchemaPostDTO(); + schemaPostDTO.setContext(" "); + schemaPostDTO.setSchema("anything"); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(schemaPostDTO) + .when() + .post(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void getSchemaByContext_blankContext_returns400_withStatusResponse() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/context/%20%20%20") + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void getSchemaByType_blankType_returns400_withStatusResponse() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/type/%20%20%20") + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + private SchemaConfig getAnyExistingSchemaConfig() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .extract() + .response(); + + SchemaConfig[] schemaConfigs = response.as(SchemaConfig[].class); + if (schemaConfigs == null || schemaConfigs.length == 0) { + return null; + } + for (SchemaConfig schemaConfig : schemaConfigs) { + if (schemaConfig != null && schemaConfig.getUniqueId() != null && !schemaConfig.getUniqueId().trim().isEmpty()) { + return schemaConfig; + } + } + return null; + } + + private String mutateSchemaIdInPackedSchema(String packedSchema, String oldSchemaId, String newSchemaId) { + if (packedSchema == null) { + return null; + } + if (oldSchemaId == null || oldSchemaId.trim().isEmpty()) { + return packedSchema; + } + if (newSchemaId == null || newSchemaId.trim().isEmpty()) { + return packedSchema; + } + return packedSchema.replace(oldSchemaId, newSchemaId); + } + + private void assertSafeToDeleteSchemaId(String schemaId) { + if (schemaId == null) { + Assertions.fail("Refusing to delete null schemaId"); + return; + } + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/server/CacheManagementApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/server/CacheManagementApiTest.java new file mode 100644 index 000000000..3d693a974 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/server/CacheManagementApiTest.java @@ -0,0 +1,58 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.server; + +import io.mapsmessaging.dto.rest.cache.CacheInfo; +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; +import static org.hamcrest.Matchers.notNullValue; + +public class CacheManagementApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server/cache"; + + @Test + void getCacheInformation_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH) + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + CacheInfo cacheInfo = response.as(CacheInfo.class); + Assertions.assertNotNull(cacheInfo); + } + + @Test + void clearCacheInformation_returns204_noBody() { + givenAuthenticated() + .when() + .delete(BASE_PATH) + .then() + .statusCode(204); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerDetailsApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerDetailsApiTest.java new file mode 100644 index 000000000..ff62ab6ea --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerDetailsApiTest.java @@ -0,0 +1,95 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.server; + +import io.mapsmessaging.dto.rest.ServerInfoDTO; +import io.mapsmessaging.dto.rest.ServerStatisticsDTO; +import io.mapsmessaging.rest.ApiTestBase; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; +import static org.hamcrest.Matchers.notNullValue; + +public class ServerDetailsApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server/details"; + + @Test + void getBuildInfo_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/info") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + ServerInfoDTO serverInfoDTO = response.as(ServerInfoDTO.class); + Assertions.assertNotNull(serverInfoDTO); + } + + @Test + void getBuildInfo_secondCall_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/info") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + ServerInfoDTO serverInfoDTO = response.as(ServerInfoDTO.class); + Assertions.assertNotNull(serverInfoDTO); + } + + @Test + void getStats_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/stats") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + ServerStatisticsDTO serverStatisticsDTO = response.as(ServerStatisticsDTO.class); + Assertions.assertNotNull(serverStatisticsDTO); + } + + @Test + void getStats_secondCall_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/stats") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + ServerStatisticsDTO serverStatisticsDTO = response.as(ServerStatisticsDTO.class); + Assertions.assertNotNull(serverStatisticsDTO); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerHealthApiTest.java b/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerHealthApiTest.java new file mode 100644 index 000000000..dfadfee0e --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/api/impl/server/ServerHealthApiTest.java @@ -0,0 +1,105 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.api.impl.server; + +import io.mapsmessaging.dto.rest.system.SubSystemStatusDTO; +import io.mapsmessaging.rest.ApiTestBase; +import io.mapsmessaging.rest.responses.ServerHealthStateResponse; +import io.mapsmessaging.rest.responses.StatusResponse; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static io.mapsmessaging.rest.api.Constants.URI_PATH; +import static org.hamcrest.Matchers.notNullValue; + +public class ServerHealthApiTest extends ApiTestBase { + + private static final String BASE_PATH = URI_PATH + "/server"; + + @Test + void getServerStatus_returns200_andHasArrayBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/status") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + SubSystemStatusDTO[] statusArray = response.as(SubSystemStatusDTO[].class); + Assertions.assertNotNull(statusArray); + } + + @Test + void getServerHealthSummary_returns200_andHasBody() { + Response response = givenAuthenticated() + .when() + .get(BASE_PATH + "/health") + .then() + .statusCode(200) + .body(notNullValue()) + .extract() + .response(); + + ServerHealthStateResponse serverHealthStateResponse = response.as(ServerHealthStateResponse.class); + Assertions.assertNotNull(serverHealthStateResponse); + } + + @Test + void serverAction_blankState_returns400_withStatusResponse() { + ServerActionRequest serverActionRequest = new ServerActionRequest(""); + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(serverActionRequest) + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } + + @Test + void serverAction_unknownState_returns400_withStatusResponse() { + String requestedState = "it_unknown_" + System.currentTimeMillis(); + ServerActionRequest serverActionRequest = new ServerActionRequest(requestedState); + + Response response = givenAuthenticated() + .contentType(ContentType.JSON) + .body(serverActionRequest) + .when() + .patch(BASE_PATH) + .then() + .statusCode(400) + .extract() + .response(); + + StatusResponse statusResponse = response.as(StatusResponse.class); + Assertions.assertNotNull(statusResponse); + Assertions.assertNotNull(statusResponse.getStatus()); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/cache/CacheTest.java b/src/test/java/io/mapsmessaging/rest/cache/CacheTest.java index 0fb5f42a9..b33499142 100644 --- a/src/test/java/io/mapsmessaging/rest/cache/CacheTest.java +++ b/src/test/java/io/mapsmessaging/rest/cache/CacheTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/rest/cache/impl/RoleBasedCacheTest.java b/src/test/java/io/mapsmessaging/rest/cache/impl/RoleBasedCacheTest.java new file mode 100644 index 000000000..32cb59770 --- /dev/null +++ b/src/test/java/io/mapsmessaging/rest/cache/impl/RoleBasedCacheTest.java @@ -0,0 +1,195 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.rest.cache.impl; + +import io.mapsmessaging.dto.rest.cache.CacheInfo; +import io.mapsmessaging.rest.cache.CacheKey; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class RoleBasedCacheTest { + + @Test + void putAndGetShouldReturnValueAndCountHit() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + cache.put(key, "value1"); + + String value = cache.get(key); + assertEquals("value1", value); + + CacheInfo info = cache.getCacheInfo(); + assertEquals(1L, info.getCacheHits()); + assertEquals(0L, info.getCacheMisses()); + assertEquals(1L, info.getCacheSize()); + assertEquals(10_000L, info.getLifeTime()); + assertEquals(60_000L, info.getScanTime()); + assertTrue(info.isEnabled()); + cache.close(); + } + + @Test + void getMissingKeyShouldReturnNullAndCountMiss() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + String value = cache.get(key); + assertNull(value); + + CacheInfo info = cache.getCacheInfo(); + assertEquals(0L, info.getCacheHits()); + assertEquals(1L, info.getCacheMisses()); + assertEquals(0L, info.getCacheSize()); + cache.close(); + } + + @Test + void expiredEntryShouldReturnNullRemoveEntryAndCountMiss() throws Exception { + RoleBasedCache cache = new RoleBasedCache<>(50, 60_000); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + cache.put(key, "value1"); + + Thread.sleep(80); + + String value = cache.get(key); + assertNull(value); + assertEquals(0L, cache.size()); + + CacheInfo info = cache.getCacheInfo(); + assertEquals(0L, info.getCacheHits()); + assertEquals(1L, info.getCacheMisses()); + assertEquals(0L, info.getCacheSize()); + cache.close(); + } + + @Test + void putShouldNotOverwriteExistingNonExpiredEntry() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + cache.put(key, "value1"); + cache.put(key, "value2"); + + String value = cache.get(key); + assertEquals("value1", value, "computeIfAbsent means put() does not overwrite a non-expired entry"); + cache.close(); + } + + @Test + void removeShouldDeleteEntry() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + cache.put(key, "value1"); + assertEquals(1L, cache.size()); + + cache.remove(key); + assertEquals(0L, cache.size()); + assertNull(cache.get(key)); + + CacheInfo info = cache.getCacheInfo(); + assertEquals(0L, info.getCacheHits()); + assertEquals(1L, info.getCacheMisses()); + cache.close(); + } + + @Test + void clearShouldRemoveAllEntries() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey key1 = Mockito.mock(CacheKey.class); + Mockito.when(key1.getEndpoint()).thenReturn("admin/users/1"); + + CacheKey key2 = Mockito.mock(CacheKey.class); + Mockito.when(key2.getEndpoint()).thenReturn("public/info"); + + cache.put(key1, "a"); + cache.put(key2, "b"); + assertEquals(2L, cache.size()); + + cache.clear(); + assertEquals(0L, cache.size()); + assertNull(cache.get(key1)); + assertNull(cache.get(key2)); + cache.close(); + } + + @Test + void removePathShouldRemoveMatchingEndpointPrefixWithOrWithoutLeadingSlash() { + RoleBasedCache cache = new RoleBasedCache<>(10_000, 60_000); + + CacheKey k1 = Mockito.mock(CacheKey.class); + Mockito.when(k1.getEndpoint()).thenReturn("admin/users/1"); + + CacheKey k2 = Mockito.mock(CacheKey.class); + Mockito.when(k2.getEndpoint()).thenReturn("admin/users/2"); + + CacheKey k3 = Mockito.mock(CacheKey.class); + Mockito.when(k3.getEndpoint()).thenReturn("public/info"); + + cache.put(k1, "a"); + cache.put(k2, "b"); + cache.put(k3, "c"); + + cache.removePath("/admin/users"); + + assertNull(cache.get(k1)); + assertNull(cache.get(k2)); + assertEquals("c", cache.get(k3)); + assertEquals(1L, cache.size()); + cache.close(); + } + + @Test + void scheduledCleanupShouldEvictExpiredEntriesEventually() throws Exception { + RoleBasedCache cache = new RoleBasedCache<>(60, 20); + + CacheKey key = Mockito.mock(CacheKey.class); + Mockito.when(key.getEndpoint()).thenReturn("users/me"); + + cache.put(key, "value1"); + assertEquals(1L, cache.size()); + + long deadline = System.currentTimeMillis() + Duration.ofSeconds(2).toMillis(); + while (System.currentTimeMillis() < deadline && cache.size() > 0) { + Thread.sleep(10); + } + + assertEquals(0L, cache.size(), "scheduled cleanup should remove expired entries"); + assertNull(cache.get(key)); + cache.close(); + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/test/BaseTestConfig.java b/src/test/java/io/mapsmessaging/test/BaseTestConfig.java index d13d64464..28fcdcaa5 100644 --- a/src/test/java/io/mapsmessaging/test/BaseTestConfig.java +++ b/src/test/java/io/mapsmessaging/test/BaseTestConfig.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -21,6 +21,9 @@ import io.mapsmessaging.BaseTest; import io.mapsmessaging.MessageDaemon; +import io.mapsmessaging.api.MessageListener; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.SessionContextBuilder; import io.mapsmessaging.api.features.DestinationType; import io.mapsmessaging.auth.AuthManager; import io.mapsmessaging.auth.ServerPermissions; @@ -29,6 +32,7 @@ import io.mapsmessaging.engine.destination.DestinationImpl; import io.mapsmessaging.engine.destination.DestinationManagerListener; import io.mapsmessaging.engine.destination.subscription.SubscriptionController; +import io.mapsmessaging.engine.session.FakeProtocol; import io.mapsmessaging.engine.session.SessionImpl; import io.mapsmessaging.engine.session.SessionManager; import io.mapsmessaging.engine.session.SessionManagerTest; @@ -43,17 +47,22 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import io.mapsmessaging.network.ProtocolClientConnection; +import io.mapsmessaging.network.protocol.Protocol; import io.mapsmessaging.security.access.Group; import io.mapsmessaging.security.access.Identity; import io.mapsmessaging.security.authorisation.ProtectedResource; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Timeout; +import javax.security.auth.login.LoginException; + @Timeout(value = 240000, unit = TimeUnit.MILLISECONDS) public class BaseTestConfig extends BaseTest { + private static final String[] EXCLUDE_LIST = {"/aggregator", "/vcan0", "/mavlink","/semtech"}; private static final String[] USERNAMES = {"user1", "admin", "user2", "anonymous"}; private static final char[][] PASSWORDS = {"password1".toCharArray(), "admin1".toCharArray(), "password2".toCharArray(), "".toCharArray()}; private static final String[] GROUPS = {"everyone"}; @@ -62,6 +71,7 @@ public class BaseTestConfig extends BaseTest { static void setUp() { } + protected static MessageDaemon md = null; private static Thread th; @@ -73,16 +83,7 @@ static void setUp() { @AfterEach void clear(){ - Map destinations = md.getDestinationManager().get(null); - List toDelete = new ArrayList<>(); - for(DestinationImpl destination:destinations.values()){ - if(!destination.getFullyQualifiedNamespace().startsWith("$")){ - toDelete.add(destination); - } - } - for(DestinationImpl destination:toDelete){ - md.getDestinationManager().delete(destination); - } + deleteUnknownDestinations(); } @BeforeAll @@ -142,6 +143,34 @@ static void beforeAll() throws IOException { } } + + public Session createSession(String name, int keepAlive, int expiry, boolean persistent, MessageListener listener) throws LoginException, IOException { + return createSession(name, keepAlive, expiry, persistent, listener, false); + } + + public Session createSession(String name, int keepAlive, int expiry, boolean persistent, MessageListener listener, boolean resetState) throws LoginException, IOException { + Protocol fakeProtocol = new FakeProtocol(listener); + SessionContextBuilder scb = new SessionContextBuilder(name, new ProtocolClientConnection(fakeProtocol)); + scb.setPersistentSession(true) + .setPersistentSession(persistent) + .setResetState(resetState) + .setReceiveMaximum(100) + .setSessionExpiry(expiry); + return createSession(scb, fakeProtocol); + } + + public Session createSession(SessionContextBuilder scb, MessageListener listener) throws LoginException, IOException { + Session session = io.mapsmessaging.api.SessionManager.getInstance().create(scb.build(), listener); + session.login(); + session.resumeState(); + return session; + } + + public void close(Session session) throws IOException { + io.mapsmessaging.api.SessionManager.getInstance().close(session, false); + } + + private static void setIfNot(String key, String value){ if(System.getProperty(key) == null){ System.setProperty(key, value); @@ -149,71 +178,16 @@ private static void setIfNot(String key, String value){ } @AfterEach - public void checkSessionState() { + void checkSessionState() { try { - SessionManager manager = md.getSubSystemManager().getSessionManager(); - List sessionImpls = manager.getSessions(); - for (SessionImpl sessionImpl : sessionImpls) { - System.err.println("Session still active::" + sessionImpl.getName()); - sessionImpl.setExpiryTime(1); - manager.close(sessionImpl, false); - } - int counter =0; - while(!sessionImpls.isEmpty() && counter < 20) { - TimeUnit.MILLISECONDS.sleep(100); - counter++; - } - - List idleSessions = SessionManagerTest.getInstance().getIdleSessions(); - for (String idleSession : idleSessions) { - System.err.println("Idle Session still active::" + idleSession); - SessionManagerTest.getInstance().closeIdleSession(idleSession); - } - - Map destinationImpls = md.getDestinationManager().get(null); - for (DestinationImpl destinationImpl : destinationImpls.values()) { - if (!destinationImpl.getFullyQualifiedNamespace().startsWith("$")) { - md.getDestinationManager().delete(destinationImpl); - } - } - if(md.getSubSystemManager().getSessionManager().hasSessions()){ - for (SessionImpl sessionImpl : md.getSubSystemManager().getSessionManager().getSessions()) { - System.err.println("Session still active::" + sessionImpl.getName()); - sessionImpl.setExpiryTime(1); - manager.close(sessionImpl, false); - } - } - Assertions.assertFalse(md.getSubSystemManager().getSessionManager().hasSessions()); - long timeout = System.currentTimeMillis()+ 10_000; - while(SessionManagerTest.getInstance().hasIdleSessions() && timeout > System.currentTimeMillis()){ - delay(100); - } - if(SessionManagerTest.getInstance().hasIdleSessions()){ - List listeners = md.getDestinationManager().getAll(); - for (String listener : listeners) { - System.err.println("has listener " + listener); - } - } - -// Assertions.assertFalse(SessionManagerTest.getInstance().hasIdleSessions()); - - List listeners = md.getDestinationManager().getListeners(); - for (DestinationManagerListener listener : listeners) { - if(listener instanceof SubscriptionController){ - SubscriptionController subscriptionController = (SubscriptionController) listener; - System.err.println("has listener " + subscriptionController.getSessionId()); - } - else { - System.err.println("has listener " + listener.getClass().toString()); - } - } + deleteUnknownDestinations(); } catch (Exception ex){ ex.printStackTrace(); } } - public String getPassword(String user) throws IOException { + public static String getPassword(String user) throws IOException { if (usernamePasswordMap == null) { if (md != null && md.isStarted() && AuthManager.getInstance().isAuthenticationEnabled()) { ConfigurationProperties properties = new ConfigurationProperties(AuthManager.getInstance().getConfig().getAuthConfig()); @@ -243,6 +217,25 @@ public void run() { } } + private void deleteUnknownDestinations(){ + Map destinations = md.getDestinationManager().get(null); + List toDelete = new ArrayList<>(); + for(DestinationImpl destination:destinations.values()){ + boolean allow = true; + for(String exclude:EXCLUDE_LIST){ + if(destination.getFullyQualifiedNamespace().startsWith(exclude)){ + allow = false; + } + } + if(allow){ + toDelete.add(destination); + } + } + for(DestinationImpl destination:toDelete){ + md.getDestinationManager().delete(destination); + } + } + private static final AtomicInteger destinationInc = new AtomicInteger(0); protected String getTopicName(){ diff --git a/src/test/java/io/mapsmessaging/test/Condition.java b/src/test/java/io/mapsmessaging/test/Condition.java index 2325b2dee..c44da5cc8 100644 --- a/src/test/java/io/mapsmessaging/test/Condition.java +++ b/src/test/java/io/mapsmessaging/test/Condition.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/test/SimpleBufferBasedTest.java b/src/test/java/io/mapsmessaging/test/SimpleBufferBasedTest.java index 2bc9f99e2..772ab4eb7 100644 --- a/src/test/java/io/mapsmessaging/test/SimpleBufferBasedTest.java +++ b/src/test/java/io/mapsmessaging/test/SimpleBufferBasedTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/test/TestFeatureManager.java b/src/test/java/io/mapsmessaging/test/TestFeatureManager.java index 818f73490..e2085872f 100644 --- a/src/test/java/io/mapsmessaging/test/TestFeatureManager.java +++ b/src/test/java/io/mapsmessaging/test/TestFeatureManager.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/test/WaitForState.java b/src/test/java/io/mapsmessaging/test/WaitForState.java index 2e69dca8e..506825fdd 100644 --- a/src/test/java/io/mapsmessaging/test/WaitForState.java +++ b/src/test/java/io/mapsmessaging/test/WaitForState.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonInstanceGenerator.java b/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonInstanceGenerator.java new file mode 100644 index 000000000..0eb157445 --- /dev/null +++ b/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonInstanceGenerator.java @@ -0,0 +1,588 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.tools.config.schema.tests; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.*; + +import java.util.*; + +public class JsonInstanceGenerator { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final int MAX_DEPTH = 12; + private static final int OPTIONAL_INCLUDE_PERCENT = 30; + + private final Random random; + + public JsonInstanceGenerator(long seed) { + this.random = new Random(seed); + } + + public JsonNode generateValidInstance(JsonNode schemaRoot) { + JsonNode rootSchema = resolveRootSchema(schemaRoot); + return generateForSchema(schemaRoot, rootSchema, 0); + } + + public JsonNode makeInvalidMissingRequired(JsonNode schemaRoot, JsonNode validInstance) { + JsonNode rootSchema = resolveRootSchema(schemaRoot); + + if (!validInstance.isObject()) { + return validInstance; + } + + ObjectNode mutated = ((ObjectNode) validInstance).deepCopy(); + Set required = readRequired(rootSchema); + + + for (String requiredName : required) { + if ("schemaLoadingVersion".equals(requiredName)) { + continue; + } + mutated.remove(requiredName); + break; + } + + return mutated; + } + + public JsonNode makeInvalidWrongType(JsonNode schemaRoot, JsonNode validInstance) { + JsonNode rootSchema = resolveRootSchema(schemaRoot); + + if (!validInstance.isObject()) { + return validInstance; + } + + ObjectNode mutated = ((ObjectNode) validInstance).deepCopy(); + + JsonNode properties = rootSchema.get("properties"); + if (properties == null || !properties.isObject()) { + return mutated; + } + + Iterator fieldNames = properties.fieldNames(); + while (fieldNames.hasNext()) { + String field = fieldNames.next(); + JsonNode fieldSchema = resolveRef(schemaRoot, properties.get(field)); + + Set allowedTypes = collectAllowedTypes(schemaRoot, fieldSchema); + if (allowedTypes.isEmpty()) { + continue; + } + + JsonNode wrongTypeValue = makeValueOfTypeNotAllowed(allowedTypes); + if (wrongTypeValue == null) { + continue; + } + + mutated.set(field, wrongTypeValue); + return mutated; + } + + return mutated; + } + + private Set collectAllowedTypes(JsonNode schemaRoot, JsonNode schema) { + Set types = new LinkedHashSet<>(); + collectAllowedTypesInto(schemaRoot, schema, types, 0); + return types; + } + + private void collectAllowedTypesInto(JsonNode schemaRoot, JsonNode schema, Set out, int depth) { + if (schema == null || schema.isMissingNode() || depth > 20) { + return; + } + + JsonNode resolved = resolveRef(schemaRoot, schema); + + JsonNode typeNode = resolved.get("type"); + if (typeNode != null) { + if (typeNode.isTextual()) { + out.add(typeNode.asText()); + } else if (typeNode.isArray()) { + for (JsonNode t : typeNode) { + if (t.isTextual()) { + out.add(t.asText()); + } + } + } + } + + JsonNode anyOf = resolved.get("anyOf"); + if (anyOf != null && anyOf.isArray()) { + for (JsonNode branch : anyOf) { + collectAllowedTypesInto(schemaRoot, branch, out, depth + 1); + } + } + + JsonNode oneOf = resolved.get("oneOf"); + if (oneOf != null && oneOf.isArray()) { + for (JsonNode branch : oneOf) { + collectAllowedTypesInto(schemaRoot, branch, out, depth + 1); + } + } + } + + private JsonNode makeValueOfTypeNotAllowed(Set allowed) { + // Pick a JSON type that is NOT allowed, in a stable preference order. + if (!allowed.contains("object")) { + ObjectNode obj = JsonNodeFactory.instance.objectNode(); + obj.put("wrong", true); + return obj; + } + if (!allowed.contains("array")) { + ArrayNode arr = JsonNodeFactory.instance.arrayNode(); + arr.add(1); + return arr; + } + if (!allowed.contains("string")) { + return TextNode.valueOf("wrong-type"); + } + if (!allowed.contains("integer")) { + return IntNode.valueOf(123); + } + if (!allowed.contains("number")) { + return DoubleNode.valueOf(1.23); + } + if (!allowed.contains("boolean")) { + return BooleanNode.TRUE; + } + if (!allowed.contains("null")) { + return NullNode.getInstance(); + } + + // Everything allowed? Then you can't create a "wrong type" for this field. + return null; + } + + + public JsonNode makeInvalidOutOfRangeOrEnum(JsonNode schemaRoot, JsonNode validInstance) { + JsonNode rootSchema = resolveRootSchema(schemaRoot); + + if (!validInstance.isObject()) { + return validInstance; + } + + ObjectNode mutated = ((ObjectNode) validInstance).deepCopy(); + JsonNode properties = rootSchema.get("properties"); + if (properties == null || !properties.isObject()) { + return mutated; + } + + for (Iterator it = properties.fieldNames(); it.hasNext(); ) { + String field = it.next(); + JsonNode fieldSchema = resolveRef(schemaRoot, properties.get(field)); + + JsonNode enumNode = fieldSchema.get("enum"); + if (enumNode != null && enumNode.isArray() && enumNode.size() > 0) { + String type = singleType(fieldSchema); + mutated.set(field, invalidEnumValue(type)); + return mutated; + } + + if (fieldSchema.has("minimum") || fieldSchema.has("maximum")) { + String type = singleType(fieldSchema); + if ("integer".equals(type)) { + long max = fieldSchema.has("maximum") ? fieldSchema.get("maximum").asLong() : Long.MAX_VALUE; + mutated.set(field, LongNode.valueOf(safePlusOne(max))); + return mutated; + } + if ("number".equals(type)) { + double max = fieldSchema.has("maximum") ? fieldSchema.get("maximum").asDouble() : Double.MAX_VALUE; + mutated.set(field, DoubleNode.valueOf(max + 1.0)); + return mutated; + } + } + } + + return mutated; + } + + // ------------------------- Core generation ------------------------- + + private JsonNode generateForSchema(JsonNode schemaRoot, JsonNode schema, int depth) { + if (depth > MAX_DEPTH) { + return NullNode.getInstance(); + } + + JsonNode resolved = resolveRef(schemaRoot, schema); + + // If a default is present, prefer it (keeps tests stable and schema-aligned) + JsonNode def = resolved.get("default"); + if (def != null && !def.isNull()) { + return def.deepCopy(); + } + + // Handle oneOf (polymorphism) early + JsonNode oneOf = resolved.get("oneOf"); + if (oneOf != null && oneOf.isArray() && oneOf.size() > 0) { + return generateFromOneOf(schemaRoot, resolved, depth); + } + + String type = singleType(resolved); + + if ("object".equals(type) || resolved.has("properties")) { + return generateObject(schemaRoot, resolved, depth); + } + + if ("array".equals(type)) { + return generateArray(schemaRoot, resolved, depth); + } + + return generatePrimitive(resolved); + } + + private ObjectNode generateObject(JsonNode schemaRoot, JsonNode schema, int depth) { + ObjectNode obj = OBJECT_MAPPER.createObjectNode(); + + JsonNode properties = schema.get("properties"); + Set required = readRequired(schema); + + if (properties != null && properties.isObject()) { + // Inject discriminator/type early if schema defines it + maybeInjectTypeFromSchema(obj, schema); + + Iterator names = properties.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + + JsonNode propSchema = resolveRef(schemaRoot, properties.get(name)); + + boolean isRequired = required.contains(name); + boolean includeOptional = random.nextInt(100) < OPTIONAL_INCLUDE_PERCENT; + + if (!isRequired && !includeOptional) { + continue; + } + + JsonNode value = generateForSchema(schemaRoot, propSchema, depth + 1); + obj.set(name, value); + } + + enforceSchemaLoadingVersionIfPresent(obj, properties); + } + + return obj; + } + + private ArrayNode generateArray(JsonNode schemaRoot, JsonNode schema, int depth) { + ArrayNode array = OBJECT_MAPPER.createArrayNode(); + + JsonNode items = schema.get("items"); + if (items == null) { + return array; + } + + JsonNode itemSchema = resolveRef(schemaRoot, items); + + // Keep stable; you can fuzz later + int count = 1; + for (int i = 0; i < count; i++) { + array.add(generateForSchema(schemaRoot, itemSchema, depth + 1)); + } + + return array; + } + + private JsonNode generatePrimitive(JsonNode schema) { + String type = singleType(schema); + + if ("string".equals(type)) { + return TextNode.valueOf(generateString(schema)); + } + if ("boolean".equals(type)) { + return BooleanNode.valueOf(generateBoolean(schema)); + } + if ("integer".equals(type)) { + return LongNode.valueOf(generateInteger(schema)); + } + if ("number".equals(type)) { + return DoubleNode.valueOf(generateNumber(schema)); + } + + // If unknown: best effort + return NullNode.getInstance(); + } + + // ------------------------- oneOf + discriminator ------------------------- + + private JsonNode generateFromOneOf(JsonNode schemaRoot, JsonNode parentSchema, int depth) { + JsonNode oneOf = parentSchema.get("oneOf"); + int index = random.nextInt(oneOf.size()); + + JsonNode chosenRaw = oneOf.get(index); + JsonNode chosen = resolveRef(schemaRoot, chosenRaw); + + // Generate the chosen branch + JsonNode generated = generateForSchema(schemaRoot, chosen, depth); + + if (!generated.isObject()) { + return generated; + } + + ObjectNode obj = (ObjectNode) generated; + + // If parent has discriminator, inject it + JsonNode discriminator = parentSchema.get("discriminator"); + if (discriminator != null && discriminator.isObject()) { + String propertyName = discriminator.has("propertyName") + ? discriminator.get("propertyName").asText() + : "type"; + + if (!obj.has(propertyName)) { + String typeValue = inferDiscriminatorValue(discriminator, chosen); + obj.put(propertyName, typeValue); + } + } else { + // No discriminator block: still inject type if possible from chosen branch + maybeInjectTypeFromSchema(obj, chosen); + } + + // schemaLoadingVersion always 1 (if present) + JsonNode props = chosen.get("properties"); + if (props != null && props.isObject()) { + enforceSchemaLoadingVersionIfPresent(obj, props); + } + + return obj; + } + + private String inferDiscriminatorValue(JsonNode discriminator, JsonNode chosenSchema) { + // 1) If chosen schema defines type as const/enum, use that + JsonNode properties = chosenSchema.get("properties"); + if (properties != null && properties.isObject()) { + JsonNode typeProp = properties.get("type"); + if (typeProp != null) { + JsonNode c = typeProp.get("const"); + if (c != null && c.isValueNode()) { + return c.asText(); + } + JsonNode e = typeProp.get("enum"); + if (e != null && e.isArray() && e.size() > 0) { + return e.get(0).asText(); + } + } + } + + // 2) If mapping exists, pick first key deterministically + JsonNode mapping = discriminator.get("mapping"); + if (mapping != null && mapping.isObject()) { + Iterator keys = mapping.fieldNames(); + if (keys.hasNext()) { + return keys.next(); + } + } + + // 3) Last resort + return "tcp"; + } + + private void maybeInjectTypeFromSchema(ObjectNode obj, JsonNode schema) { + if (obj.has("type")) { + return; + } + + JsonNode properties = schema.get("properties"); + if (properties == null || !properties.isObject()) { + return; + } + + JsonNode typeProp = properties.get("type"); + if (typeProp == null) { + return; + } + + JsonNode c = typeProp.get("const"); + if (c != null && c.isValueNode()) { + obj.put("type", c.asText()); + return; + } + + JsonNode e = typeProp.get("enum"); + if (e != null && e.isArray() && e.size() > 0) { + obj.put("type", e.get(0).asText()); + } + } + + private void enforceSchemaLoadingVersionIfPresent(ObjectNode obj, JsonNode properties) { + if (properties.has("schemaLoadingVersion")) { + obj.put("schemaLoadingVersion", 1); + } + } + + // ------------------------- Value generators ------------------------- + + private String generateString(JsonNode schema) { + JsonNode examples = schema.get("examples"); + if (examples != null && examples.isArray() && examples.size() > 0 && examples.get(0).isTextual()) { + return examples.get(0).asText(); + } + + JsonNode def = schema.get("default"); + if (def != null && def.isTextual()) { + return def.asText(); + } + + JsonNode enumNode = schema.get("enum"); + if (enumNode != null && enumNode.isArray() && enumNode.size() > 0 && enumNode.get(0).isTextual()) { + return enumNode.get(0).asText(); + } + + return "value_" + Math.abs(random.nextInt()); + } + + private boolean generateBoolean(JsonNode schema) { + JsonNode def = schema.get("default"); + if (def != null && def.isBoolean()) { + return def.asBoolean(); + } + return random.nextBoolean(); + } + + private long generateInteger(JsonNode schema) { + JsonNode enumNode = schema.get("enum"); + if (enumNode != null && enumNode.isArray() && enumNode.size() > 0) { + return enumNode.get(0).asLong(); + } + + long min = schema.has("minimum") ? schema.get("minimum").asLong() : 0; + long max = schema.has("maximum") ? schema.get("maximum").asLong() : min + 100; + if (max < min) { + max = min; + } + if (max == min) { + return min; + } + + long bound = (max - min) + 1; + long r = Math.floorMod(random.nextLong(), bound); + return min + r; + } + + private double generateNumber(JsonNode schema) { + JsonNode enumNode = schema.get("enum"); + if (enumNode != null && enumNode.isArray() && enumNode.size() > 0) { + return enumNode.get(0).asDouble(); + } + + double min = schema.has("minimum") ? schema.get("minimum").asDouble() : 0.0; + double max = schema.has("maximum") ? schema.get("maximum").asDouble() : min + 100.0; + if (max < min) { + max = min; + } + if (Double.compare(max, min) == 0) { + return min; + } + + // Avoid endpoint values for stability + double v = min + (random.nextDouble() * (max - min)); + return v; + } + + private JsonNode invalidEnumValue(String type) { + if ("string".equals(type)) { + return TextNode.valueOf("___NOT_IN_ENUM___"); + } + if ("integer".equals(type)) { + return LongNode.valueOf(Long.MAX_VALUE); + } + if ("number".equals(type)) { + return DoubleNode.valueOf(9.223372036854776E18); + } + if ("boolean".equals(type)) { + return TextNode.valueOf("___NOT_A_BOOLEAN___"); + } + return TextNode.valueOf("___NOT_IN_ENUM___"); + } + + private long safePlusOne(long value) { + if (value == Long.MAX_VALUE) { + return Long.MAX_VALUE; + } + return value + 1; + } + + // ------------------------- Schema helpers ------------------------- + + private Set readRequired(JsonNode schema) { + Set required = new LinkedHashSet<>(); + JsonNode requiredNode = schema.get("required"); + if (requiredNode != null && requiredNode.isArray()) { + for (JsonNode n : requiredNode) { + if (n.isTextual()) { + required.add(n.asText()); + } + } + } + return required; + } + + private String singleType(JsonNode schema) { + JsonNode type = schema.get("type"); + if (type == null) { + return null; + } + if (type.isTextual()) { + return type.asText(); + } + if (type.isArray() && type.size() > 0 && type.get(0).isTextual()) { + return type.get(0).asText(); + } + return null; + } + + private JsonNode resolveRootSchema(JsonNode schemaRoot) { + JsonNode ref = schemaRoot.get("$ref"); + if (ref != null && ref.isTextual()) { + return resolveRef(schemaRoot, schemaRoot); + } + return schemaRoot; + } + + private JsonNode resolveRef(JsonNode schemaRoot, JsonNode node) { + if (node == null) { + return NullNode.getInstance(); + } + + JsonNode ref = node.get("$ref"); + if (ref == null || !ref.isTextual()) { + return node; + } + + String refText = ref.asText(); + if (!refText.startsWith("#/")) { + return node; + } + + String[] parts = refText.substring(2).split("/"); + JsonNode current = schemaRoot; + + for (String part : parts) { + current = current.get(part); + if (current == null) { + return node; + } + } + + return current; + } +} diff --git a/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonSchemaRoundTripTest.java b/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonSchemaRoundTripTest.java new file mode 100644 index 000000000..bd5d7e405 --- /dev/null +++ b/src/test/java/io/mapsmessaging/tools/config/schema/tests/JsonSchemaRoundTripTest.java @@ -0,0 +1,372 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.tools.config.schema.tests; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.dialect.Dialects; +import io.mapsmessaging.dto.rest.auth.SecurityManagerDTO; +import io.mapsmessaging.dto.rest.config.*; +import io.mapsmessaging.dto.rest.config.lora.LoRaDeviceConfigDTO; +import io.mapsmessaging.dto.rest.config.ml.MLModelManagerDTO; +import io.mapsmessaging.dto.rest.schema.SchemaManagerConfigDTO; +import io.mapsmessaging.tools.config.schema.RuntimeJsonSchemaGenerator; +import io.mapsmessaging.tools.config.schema.RuntimeJsonSchemaService; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + + +class JsonSchemaRoundTripTest { + + private static final Map> dtoMap = buildMap(); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true); + + private final JsonSchemaValidator validator; + + public JsonSchemaRoundTripTest() { + this.validator = new NetworkNtJsonSchemaValidator(); // replace with real impl + } + + @TestFactory + public Stream roundTripAllSchemas() { + // a) Create schemas + RuntimeJsonSchemaGenerator generator = new RuntimeJsonSchemaGenerator(); + RuntimeJsonSchemaService service = new RuntimeJsonSchemaService(generator); + Map schemas = service.generateAllSchemas(); + + // Deterministic randomness (so failures are reproducible) + long seed = 123456789L; + + return schemas.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .flatMap(entry -> { + String schemaName = entry.getKey(); + String schemaText = entry.getValue(); + + Class dtoClass =dtoMap.get(schemaName); + return Stream.of( + DynamicTest.dynamicTest(schemaName + " :: valid round-trip", () -> + runValidRoundTrip(schemaName, schemaText, dtoClass, seed)), + DynamicTest.dynamicTest(schemaName + " :: invalid (missing required)", () -> + runInvalidMissingRequired(schemaName, schemaText, dtoClass, seed)), + DynamicTest.dynamicTest(schemaName + " :: invalid (wrong type)", () -> + runInvalidWrongType(schemaName, schemaText, dtoClass, seed)), + DynamicTest.dynamicTest(schemaName + " :: invalid (out of range/enum)", () -> + runInvalidOutOfRangeOrEnum(schemaName, schemaText, dtoClass, seed)) + ); + }); + + } + + private static Map> buildMap(){ + Map> map = new HashMap<>(); + map.put("AggregatorManager", AggregatorManagerConfigDTO.class); + map.put("AuthManager", AuthManagerConfigDTO.class); + map.put("DestinationManager", DestinationManagerConfigDTO.class); + map.put("DeviceManager", DeviceManagerConfigDTO.class); + map.put("DiscoveryManager", DiscoveryManagerConfigDTO.class); + map.put("License", LicenseManagerConfigDTO.class); + map.put("LoRaDevice", LoRaDeviceManagerConfigDTO.class); + map.put("MLModelManager", MLModelManagerDTO.class); + map.put("MessageDaemon", MessageDaemonConfigDTO.class); + map.put("NetworkConnectionManager", NetworkConnectionManagerConfigDTO.class); + map.put("NetworkManager", NetworkManagerConfigDTO.class); + map.put("RestApi", RestApiManagerConfigDTO.class); + map.put("SchemaManager", SchemaManagerConfigDTO.class); + map.put("SecurityManager", SecurityManagerDTO.class); + map.put("TenantManagement", TenantManagementConfigDTO.class); + map.put("jolokia", JolokiaConfigDTO.class); + map.put("routing", RoutingManagerConfigDTO.class); + return map; + } + private void runValidRoundTrip(String schemaName, String schemaText, Class dtoClass, long seed) throws Exception { + assertNotNull(dtoClass, () -> schemaName + " has no DTO mapping"); + + JsonNode schemaNode = parse(schemaText); + + // b) Build a valid JSON instance from schema + JsonInstanceGenerator generator = new JsonInstanceGenerator(seed); + JsonNode inputJson = generator.generateValidInstance(schemaNode); + + // Validate input JSON against schema + List inputErrors = validator.validate(schemaNode, inputJson); + assertTrue(inputErrors.isEmpty(), () -> schemaName + " valid JSON failed schema: " + inputErrors + "\nJSON: " + inputJson); + + // c) JSON -> DTO1 + Object dto1; + try { + dto1 = OBJECT_MAPPER.treeToValue(inputJson, dtoClass); + } catch (Exception e) { + fail(schemaName + " DTO generation failure (" + dtoClass.getName() + "): " + e.getMessage() + "\nJSON: " + inputJson, e); + return; + } + + // d) DTO1 -> JSON + JsonNode outputJson; + try { + outputJson = OBJECT_MAPPER.valueToTree(dto1); + } catch (Exception e) { + fail(schemaName + " JSON generation from DTO failure: " + e.getMessage(), e); + return; + } + + // Validate DTO-produced JSON against schema (still useful) + List outputErrors = validator.validate(schemaNode, outputJson); + assertTrue(outputErrors.isEmpty(), () -> schemaName + " DTO->JSON failed schema: " + outputErrors + "\nJSON: " + outputJson); + + // e) JSON -> DTO2 + Object dto2; + try { + dto2 = OBJECT_MAPPER.treeToValue(outputJson, dtoClass); + } catch (Exception e) { + fail(schemaName + " DTO regeneration failure (" + dtoClass.getName() + "): " + e.getMessage() + "\nJSON: " + outputJson, e); + return; + } + + if (!dto1.equals(dto2)) { + JsonNode dto1Json = OBJECT_MAPPER.valueToTree(dto1); + JsonNode dto2Json = OBJECT_MAPPER.valueToTree(dto2); + + List diffs = JsonDiff.diff(dto1Json, dto2Json); + fail(schemaName + " DTO round-trip changed values:\n" + String.join("\n", diffs) + + "\nDTO1 JSON: " + dto1Json + + "\nDTO2 JSON: " + dto2Json); + } + } + + + private void runInvalidMissingRequired(String schemaName, String schemaText, Class dtoClass, long seed) throws Exception { + JsonNode schemaNode = parse(schemaText); + + JsonInstanceGenerator generator = new JsonInstanceGenerator(seed); + JsonNode valid = generator.generateValidInstance(schemaNode); + + JsonNode invalid = generator.makeInvalidMissingRequired(schemaNode, valid); + if(!invalid.equals(valid)) { + List errors = validator.validate(schemaNode, invalid); + assertFalse(errors.isEmpty(), () -> schemaName + " expected missing-required to fail schema validation"); + } + } + + private void runInvalidWrongType(String schemaName, String schemaText, Class dtoClass, long seed) throws Exception { + JsonNode schemaNode = parse(schemaText); + + JsonInstanceGenerator generator = new JsonInstanceGenerator(seed); + JsonNode valid = generator.generateValidInstance(schemaNode); + JsonNode invalid = generator.makeInvalidWrongType(schemaNode, valid); + if(!invalid.equals(valid)) { + List errors = validator.validate(schemaNode, invalid); + assertFalse(errors.isEmpty(), () -> schemaName + " expected wrong-type to fail schema validation"); + } + } + + private void runInvalidOutOfRangeOrEnum(String schemaName, String schemaText, Class dtoClass, long seed) throws Exception { + JsonNode schemaNode = parse(schemaText); + + JsonInstanceGenerator generator = new JsonInstanceGenerator(seed); + JsonNode valid = generator.generateValidInstance(schemaNode); + + JsonNode invalid = generator.makeInvalidOutOfRangeOrEnum(schemaNode, valid); + if(!invalid.equals(valid)) { + List errors = validator.validate(schemaNode, invalid); + assertFalse(errors.isEmpty(), () -> schemaName + " expected out-of-range/enum to fail schema validation"); + } + } + + private static JsonNode parse(String json) throws JsonProcessingException { + return OBJECT_MAPPER.readTree(json); + } + + // ------------------------- Registry ------------------------- + + public interface SchemaToDtoRegistry { + Class getDtoClass(String schemaName); + } + + public static final class MapBackedSchemaToDtoRegistry implements SchemaToDtoRegistry { + private final Map> mapping; + + public MapBackedSchemaToDtoRegistry(Map> mapping) { + this.mapping = new HashMap<>(mapping); + } + + @Override + public Class getDtoClass(String schemaName) { + return mapping.get(schemaName); + } + } + + // ------------------------- Validator (pluggable) ------------------------- + + public interface JsonSchemaValidator { + List validate(JsonNode schema, JsonNode instance); + } + + + public final class NetworkNtJsonSchemaValidator implements JsonSchemaRoundTripTest.JsonSchemaValidator { + + + private final SchemaRegistry schemaRegistry; + + public NetworkNtJsonSchemaValidator() { + this.schemaRegistry = SchemaRegistry.withDialect(Dialects.getDraft202012()); + } + + @Override + public List validate(JsonNode schemaNode, JsonNode instance) { + JsonNode effectiveSchema = resolveTopLevelRef(schemaNode); + + Schema schema = schemaRegistry.getSchema(effectiveSchema); + + // instance is already a JsonNode; no need to re-materialize it + List errors = schema.validate(instance); + + return errors.stream() + .map(Error::getMessage) + .collect(Collectors.toList()); + } + + private JsonNode resolveTopLevelRef(JsonNode schemaRoot) { + JsonNode refNode = schemaRoot.get("$ref"); + if (refNode == null || !refNode.isTextual()) { + return schemaRoot; + } + + JsonNode resolved = resolveRef(schemaRoot, schemaRoot); + if (resolved == null || !resolved.isObject()) { + return schemaRoot; + } + + // Merge: resolved subschema + carry over $defs so nested refs keep working + ObjectNode merged = ((ObjectNode) resolved).deepCopy(); + + JsonNode defs = schemaRoot.get("$defs"); + if (defs != null && defs.isObject()) { + merged.set("$defs", defs); + } + + JsonNode schemaDecl = schemaRoot.get("$schema"); + if (schemaDecl != null) { + merged.set("$schema", schemaDecl); + } + + // Keep $id if present (helps some validators that use ids internally) + JsonNode id = schemaRoot.get("$id"); + if (id != null) { + merged.set("$id", id); + } + + return merged; + } + + private JsonNode resolveRef(JsonNode schemaRoot, JsonNode node) { + JsonNode ref = node.get("$ref"); + if (ref == null || !ref.isTextual()) { + return node; + } + + String refText = ref.asText(); + if (!refText.startsWith("#/")) { + return node; + } + + String[] parts = refText.substring(2).split("/"); + JsonNode current = schemaRoot; + + for (String part : parts) { + current = current.get(part); + if (current == null) { + return node; + } + } + + return current; + } + } + static final class JsonDiff { + + static List diff(JsonNode left, JsonNode right) { + List out = new ArrayList<>(); + diffInto("$", left, right, out); + return out; + } + + private static void diffInto(String path, JsonNode left, JsonNode right, List out) { + if (left == null && right == null) { + return; + } + if (left == null || right == null) { + out.add(path + " one side is null. left=" + left + " right=" + right); + return; + } + + if (!left.getNodeType().equals(right.getNodeType())) { + out.add(path + " type differs. left=" + left.getNodeType() + " right=" + right.getNodeType() + + " leftVal=" + left + " rightVal=" + right); + return; + } + + if (left.isObject()) { + Set names = new TreeSet<>(); + left.fieldNames().forEachRemaining(names::add); + right.fieldNames().forEachRemaining(names::add); + + for (String name : names) { + JsonNode l = left.get(name); + JsonNode r = right.get(name); + diffInto(path + "." + name, l, r, out); + } + return; + } + + if (left.isArray()) { + int max = Math.max(left.size(), right.size()); + for (int i = 0; i < max; i++) { + JsonNode l = i < left.size() ? left.get(i) : null; + JsonNode r = i < right.size() ? right.get(i) : null; + diffInto(path + "[" + i + "]", l, r, out); + } + return; + } + + // value nodes + if (!left.equals(right)) { + out.add(path + " value differs. left=" + left + " right=" + right); + } + } + } + +} diff --git a/src/test/java/io/mapsmessaging/utilities/GeoHashUtilsTest.java b/src/test/java/io/mapsmessaging/utilities/GeoHashUtilsTest.java index 83faa5788..97890eb30 100644 --- a/src/test/java/io/mapsmessaging/utilities/GeoHashUtilsTest.java +++ b/src/test/java/io/mapsmessaging/utilities/GeoHashUtilsTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. @@ -17,26 +17,6 @@ * limitations under the License. */ -/* - * - * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. - * - * Licensed under the Apache License, Version 2.0 with the Commons Clause - * (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 - * https://commonsclause.com/ - * - * 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 io.mapsmessaging.utilities; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/io/mapsmessaging/utilities/filtering/NamespaceFiltersTest.java b/src/test/java/io/mapsmessaging/utilities/filtering/NamespaceFiltersTest.java new file mode 100644 index 000000000..ed71c604d --- /dev/null +++ b/src/test/java/io/mapsmessaging/utilities/filtering/NamespaceFiltersTest.java @@ -0,0 +1,174 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (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 + * https://commonsclause.com/ + * + * 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 io.mapsmessaging.utilities.filtering; + +import io.mapsmessaging.dto.rest.config.protocol.NamespaceFilterDTO; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class NamespaceFiltersTest { + + @Test + void findMatchShouldReturnNullWhenEmpty() { + NamespaceFilters filters = new NamespaceFilters(); + + NamespaceFilter match = filters.findMatch("/root/system/topic"); + assertNull(match); + } + + @Test + void addFilterShouldMatchExactNamespaceAndChildrenViaLongestPrefix() { + NamespaceFilters filters = new NamespaceFilters(); + + NamespaceFilterDTO rootSystem = dto("root/system", 0, null, false); + NamespaceFilterDTO rootSystemSub = dto("root/system/sub", 0, null, false); + + filters.addFilter(rootSystem); + filters.addFilter(rootSystemSub); + + NamespaceFilter match1 = filters.findMatch("root/system"); + assertNotNull(match1); + assertEquals("root/system", match1.getConfig().getNamespace()); + + NamespaceFilter match2 = filters.findMatch("/root/system/sub"); + assertNotNull(match2); + assertEquals("root/system/sub", match2.getConfig().getNamespace()); + + NamespaceFilter match3 = filters.findMatch("/root/system/sub/leaf/topic"); + assertNotNull(match3); + assertEquals("root/system/sub", match3.getConfig().getNamespace()); + + NamespaceFilter match4 = filters.findMatch("/root/system/other"); + assertNotNull(match4); + assertEquals("root/system", match4.getConfig().getNamespace()); + } + + @Test + void normalizeShouldTreatLeadingAndTrailingSlashesAsSameNamespace() { + NamespaceFilters filters = new NamespaceFilters(); + + filters.addFilter(dto("/root/system/", 0, null, false)); + + NamespaceFilter match1 = filters.findMatch("root/system"); + NamespaceFilter match2 = filters.findMatch("/root/system"); + NamespaceFilter match3 = filters.findMatch("/root/system/"); + NamespaceFilter match4 = filters.findMatch("/root/system/topic"); + + assertNotNull(match1); + assertNotNull(match2); + assertNotNull(match3); + assertNotNull(match4); + + assertEquals("/root/system/", match1.getConfig().getNamespace(), "DTO is stored as-is; trie uses normalized keying only"); + assertSame(match1, match2); + assertSame(match1, match3); + assertSame(match1, match4); + } + + @Test + void findMatchShouldPreferMostSpecificPrefixWhenMultipleFiltersExist() { + NamespaceFilters filters = new NamespaceFilters(); + + filters.addFilter(dto("root", 0, null, false)); + filters.addFilter(dto("root/system", 0, null, false)); + filters.addFilter(dto("root/system/a", 0, null, false)); + + NamespaceFilter match = filters.findMatch("/root/system/a/b/c"); + assertNotNull(match); + assertEquals("root/system/a", match.getConfig().getNamespace()); + } + + @Test + void addAllShouldInsertFiltersAndFindMatchShouldWork() throws Exception { + NamespaceFilters filters = new NamespaceFilters(); + + List list = new ArrayList<>(); + list.add(new NamespaceFilter(dto("root/system", 0, null, false))); + list.add(new NamespaceFilter(dto("root/system/sub", 0, null, false))); + + filters.addAll(list); + + NamespaceFilter match = filters.findMatch("/root/system/sub/topic"); + assertNotNull(match); + assertEquals("root/system/sub", match.getConfig().getNamespace()); + } + + @Test + void getAllFiltersShouldReturnAllInsertedFilters() { + NamespaceFilters filters = new NamespaceFilters(); + + NamespaceFilterDTO a = dto("root/system", 0, null, false); + NamespaceFilterDTO b = dto("root/system/sub", 0, null, false); + NamespaceFilterDTO c = dto("public/info", 0, null, false); + + filters.addFilter(a); + filters.addFilter(b); + filters.addFilter(c); + + List all = filters.getAllFilters(); + assertEquals(3, all.size()); + + assertTrue(all.stream().anyMatch(f -> "root/system".equals(f.getConfig().getNamespace()))); + assertTrue(all.stream().anyMatch(f -> "root/system/sub".equals(f.getConfig().getNamespace()))); + assertTrue(all.stream().anyMatch(f -> "public/info".equals(f.getConfig().getNamespace()))); + } + + @Test + void addFilterShouldIgnoreInvalidSelectorAndNotAddFilter() { + NamespaceFilters filters = new NamespaceFilters(); + + // Intentionally garbage, should fail compilation and be swallowed + NamespaceFilterDTO bad = dto("root/system", 0, "THIS IS NOT A SELECTOR !!!", false); + + filters.addFilter(bad); + + NamespaceFilter match = filters.findMatch("/root/system/topic"); + assertNull(match, "Invalid selector should cause filter construction to fail; addFilter swallows IOException so nothing is added"); + assertTrue(filters.getAllFilters().isEmpty()); + } + + @Test + void addFilterWithValidSelectorShouldStoreFilterAndExecutorShouldNotBeNull() { + NamespaceFilters filters = new NamespaceFilters(); + + // Use something extremely basic. If your selector grammar differs, adjust to a known-good expression. + NamespaceFilterDTO ok = dto("root/system", 0, "1 = 1", false); + + filters.addFilter(ok); + + NamespaceFilter match = filters.findMatch("/root/system/topic"); + assertNotNull(match); + assertEquals("root/system", match.getConfig().getNamespace()); + assertNotNull(match.getExecutor(), "Valid selector should compile into an executor"); + } + + private NamespaceFilterDTO dto(String namespace, int depth, String selector, boolean forcePriority) { + NamespaceFilterDTO dto = new NamespaceFilterDTO(); + dto.setNamespace(namespace); + dto.setDepth(depth); + dto.setSelector(selector); + dto.setForcePriority(forcePriority); + return dto; + } +} \ No newline at end of file diff --git a/src/test/java/io/mapsmessaging/utilities/stats/LinkedMovingAveragesTest.java b/src/test/java/io/mapsmessaging/utilities/stats/LinkedMovingAveragesTest.java index b492aa080..06717cf9d 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/LinkedMovingAveragesTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/LinkedMovingAveragesTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageFactoryTest.java b/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageFactoryTest.java index 2b639483d..5b33ece50 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageFactoryTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageFactoryTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageTest.java b/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageTest.java index ede5e261b..b963b5217 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/MovingAverageTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/SimpleStatsTest.java b/src/test/java/io/mapsmessaging/utilities/stats/SimpleStatsTest.java index 8c3d088ef..c943c469b 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/SimpleStatsTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/SimpleStatsTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/processors/AdderDataProcessorTest.java b/src/test/java/io/mapsmessaging/utilities/stats/processors/AdderDataProcessorTest.java index a047c9fb1..b0d16c295 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/processors/AdderDataProcessorTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/processors/AdderDataProcessorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/processors/AverageDataProcessorTest.java b/src/test/java/io/mapsmessaging/utilities/stats/processors/AverageDataProcessorTest.java index 573f8d62a..0e2afd792 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/processors/AverageDataProcessorTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/processors/AverageDataProcessorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/processors/DataProcessorTest.java b/src/test/java/io/mapsmessaging/utilities/stats/processors/DataProcessorTest.java index 7c8dae86a..1a4c2af23 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/processors/DataProcessorTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/processors/DataProcessorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/java/io/mapsmessaging/utilities/stats/processors/DifferenceDataProcessorTest.java b/src/test/java/io/mapsmessaging/utilities/stats/processors/DifferenceDataProcessorTest.java index a76d0fe06..1dae0d6e1 100644 --- a/src/test/java/io/mapsmessaging/utilities/stats/processors/DifferenceDataProcessorTest.java +++ b/src/test/java/io/mapsmessaging/utilities/stats/processors/DifferenceDataProcessorTest.java @@ -1,7 +1,7 @@ /* * * Copyright [ 2020 - 2024 ] Matthew Buckton - * Copyright [ 2024 - 2025 ] MapsMessaging B.V. + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. * * Licensed under the Apache License, Version 2.0 with the Commons Clause * (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/python/client_test5.py b/src/test/python/client_test5.py index c4da56ab8..3a58971cd 100644 --- a/src/test/python/client_test5.py +++ b/src/test/python/client_test5.py @@ -18,7 +18,22 @@ # # Copyright [ 2020 - 2024 ] Matthew Buckton -# Copyright [ 2024 - 2025 ] MapsMessaging B.V. +# Copyright [ 2024 - 2026 ] MapsMessaging B.V. +# +# Licensed under the Apache License, Version 2.0 with the Commons Clause +# (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 +# https://commonsclause.com/ +# +# 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. + +# # # Licensed under the Apache License, Version 2.0 with the Commons Clause # (the "License"); you may not use this file except in compliance with the License. diff --git a/src/test/resources/AggregatorManager.yaml b/src/test/resources/AggregatorManager.yaml new file mode 100644 index 000000000..9a020d06b --- /dev/null +++ b/src/test/resources/AggregatorManager.yaml @@ -0,0 +1,136 @@ +# +# Copyright [ 2024 - 2026 ] [Maps Messaging B.V.] +# +# 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. +# + +AggregatorManager: + data: + stripeCount: 0 + maxBatchPerAggregator: 128 + idleSleepMs: 1 + mailboxCapacity: 8192 + maxAggregators: 0 + + aggregatorConfigList: + - name: aggregator-1 + enabled: true + outputTopic: /aggregator1/out1 + windowDurationMs: 1000 + timeoutMs: 5000 + maxEventsPerTopic: 1 + + inputs: + - topicName: /aggregator1/in1 + contributionMode: LAST + - topicName: /aggregator1/in2 + contributionMode: LAST + - topicName: /aggregator1/in3 + contributionMode: LAST + + - name: aggregator-2 + enabled: true + outputTopic: /aggregator2/out1 + windowDurationMs: 1000 + timeoutMs: 5000 + maxEventsPerTopic: 1 + inputs: + - topicName: /aggregator2/in1 + contributionMode: LAST + outputTransformers: + name: jsonmutate + operations: + - op: SET + path: temp + value: "$.reading.temperatureC" + - op: REMOVE + path: deviceId + - op: REMOVE + path: deviceName + - op: REMOVE + path: time + - op: REMOVE + path: location + - op: REMOVE + path: battery + - op: REMOVE + path: reading + - topicName: /aggregator2/in2 + contributionMode: LAST + outputTransformers: + - + name : jsonmutate + operations: + - op: SET + value: "$.reading.humidityPct" + path: humidity + - op: REMOVE + path: reading + - op: REMOVE + path: deviceId + - op: REMOVE + path: deviceName + - op: REMOVE + path: time + - op: REMOVE + path: location + - op: REMOVE + path: battery + + - topicName: /aggregator2/in3 + contributionMode: LAST + + outputTransformers: + - + name : jsonmutate + operations: + - op: SET + value: "$.reading.pressureHpa" + path: pressure + - op: REMOVE + path: reading + - op: REMOVE + path: deviceId + - op: REMOVE + path: deviceName + - op: REMOVE + path: time + - op: REMOVE + path: location + - op: REMOVE + path: battery + + - name: aggregator-3 + enabled: true + outputTopic: /aggregator3/out1 + + windowDurationMs: 1000 + timeoutMs: 5000 + maxEventsPerTopic: 1 + + inputs: + - topicName: /aggregator3/in1 + contributionMode: LAST + - topicName: /aggregator3/in2 + contributionMode: LAST + - topicName: /aggregator3/in3 + contributionMode: LAST + outputTransformers: + name: jsonquery + query: | + { + runId: .inputs."/aggregator3/in1".payload.runId, + temp: .inputs."/aggregator3/in1".payload.temp, + humidity: .inputs."/aggregator3/in2".payload.humidity, + pressure: .inputs."/aggregator3/in3".payload.pressure + } diff --git a/src/test/resources/AuthManager.yaml b/src/test/resources/AuthManager.yaml index e1fccb9e9..7216ba34c 100644 --- a/src/test/resources/AuthManager.yaml +++ b/src/test/resources/AuthManager.yaml @@ -30,7 +30,7 @@ --- AuthManager: authenticationEnabled: true # connections will be authorised according to the config - authorizationEnabled: true # Requests will be authorised according to the access control list for the resource + authorizationEnabled: false # Requests will be authorised according to the access control list for the resource config: identityProvider: "Encrypted-Auth" passwordHandler: "EncryptedPasswordCipher" diff --git a/src/test/resources/DestinationManager.yaml b/src/test/resources/DestinationManager.yaml index 7f17c9342..7a935efd4 100644 --- a/src/test/resources/DestinationManager.yaml +++ b/src/test/resources/DestinationManager.yaml @@ -13,6 +13,34 @@ DestinationManager: type: WeakReference writeThrough: enable + + - name: root + directory: ./target/dataDirectory/compute + namespace: /compute + type: Memory + sync: disable + itemCount: 20000 + format: + name: json + + - name: root + directory: ./target/dataDirectory/stats + namespace: /stats + type: Memory + sync: disable + itemCount: 20000 + format: + name: json + + - name: root + directory: ./target/dataDirectory/aggregator3 + namespace: /aggregator3 + type: Memory + sync: disable + itemCount: 20000 + format: + name: json + - name: mqtt-sn directory: ./target/dataMqttSn namespace: /mqttsn/ diff --git a/src/test/resources/DeviceManager.yaml b/src/test/resources/DeviceManager.yaml index 73cda8db7..996efed0a 100644 --- a/src/test/resources/DeviceManager.yaml +++ b/src/test/resources/DeviceManager.yaml @@ -21,7 +21,7 @@ DeviceManager: global: - enabled: false # Enabled hardware interface + enabled: true # Enabled hardware interface trigger: everyMinute scanTime: 300000 # Scan time looking for new hardware topicNameTemplate: /device/[bus_name]/[device_name] diff --git a/src/test/resources/NetworkConnectionManager.yaml b/src/test/resources/NetworkConnectionManager.yaml index 701102c40..fdd39fcea 100644 --- a/src/test/resources/NetworkConnectionManager.yaml +++ b/src/test/resources/NetworkConnectionManager.yaml @@ -39,20 +39,19 @@ NetworkConnectionManager: selectorThreadCount : 1 data: - - name: "ST2100 Modem" url: serial://localhost:0/ protocol: stogi - transformation: "Message-Raw" initialSetup: "" incomingMessagePollInterval: 1 # 1 seconds - outgoingMessagePollInterval: 20 + outgoingMessagePollInterval: 5 sharedSecret: "This is a shared secret to use" sendHighPriorityMessages: false modemResponseTimeout: 20000 locationPollInterval: 60 maxBufferSize: 4000 compressionCutoffSize: 128 + modemRawResponse: "/outbound/#" modemStatsTopic: "/modem/stats" @@ -67,10 +66,26 @@ NetworkConnectionManager: remote_namespace: "/satellite/#" include_schema: false + - direction: push + local_namespace: "/compute/#" + remote_namespace: "/remote/compute/#" + include_schema: false + namespaceFilters: + namespace: "/compute/" + depth: 6 + + - direction: push + local_namespace: "/stats/#" + remote_namespace: "/remote/stats/#" + include_schema: false + analystics: + eventCount: 100 + keyList: "value1, value2" + defaultAnalyser: "Advanced" serial: - port: /tmp/tty.app + port: ttyV1 baudRate: 9600 dataBits: 8 stopBits: 1 diff --git a/src/test/resources/NetworkManager.yaml b/src/test/resources/NetworkManager.yaml index 69a0207fd..67dc3af9f 100644 --- a/src/test/resources/NetworkManager.yaml +++ b/src/test/resources/NetworkManager.yaml @@ -100,6 +100,31 @@ NetworkManager: data: + + - + url: udp://0.0.0.0:14550/ + name: "mavLink Test" + protocols: + type: mavlink + dialect: common + idleSessionTimeout: 300 + maximumSessionExpiry: 86400 + advertiseInterval: 15 + maxInFlightEvents: 8 + topicNameTemplate: "/mavlink/{remoteSocket}/{systemId}/{componentId}/{messageName}" + parseToJson: true + + - name: "N2K Interface" + url: canbus://::/ + deviceName: vcan0 + protocol: n2k + + - name: "CanAerospace Interface" + url: canbus://::/ + deviceName: vcan1 + protocol: canaerospace + + - name: "NATS TCP Test anon Interface for raw buffer writes" url: "tcp://0.0.0.0:4222/" protocol: nats @@ -276,6 +301,7 @@ NetworkManager: auth: anon - + name: "SSL Echo Server" url: ssl://0.0.0.0:8444/ protocol: echo auth: anon @@ -287,6 +313,12 @@ NetworkManager: maxBlockSize: 512 idleTimePeriod: 120 + - url: udp://0.0.0.0:1700/ + name: "SemTech Interface" + protocol: semtech + maxBlockSize: 1024 + idleTimePeriod: 120 + - url: ssl://0.0.0.0:9999/ name: "bad SSL interface to test" @@ -295,31 +327,23 @@ NetworkManager: ssl_keyStorePassphrase: invalidPassword - - name: NMEA GPS port - url: serial://ttyUSB0,9600,8,N,1/ - port: ttyUSB0 - baudRate: 9600 - dataBits: 8 - stopBits: 1 - parity: n - protocol: NMEA - selectorThreadCount: 1 - - - name: "Inmarsat Iot nano interface" + name: "Inmarsat Iot nano interface" protocol: satellite auth: anon url: satellite://inmarsat:0/ httpRequestTimeoutSec: 30 - pollInterval: 15 + pollInterval: 5 sharedSecret: "This is a shared secret to use" maxInflightEventsPerDevice: 2 outboundNamespaceRoot: "/inmarsat/{deviceId}" outboundBroadcast: "/inmarsat/broadcast" - baseUrl: "http://localhost:8085/v1" + outgoingMessagePollInterval: 5 + incomingMessagePollInterval: 4 + baseUrl: "http://localhost:8085/iotMessaging/v1" remoteAuthConfig: username: "this is not real" password: "this is just a junk password since we are in local mock test" mailboxId: "again not valid, not real" mailboxPassword: "whatever" - namespaceRoot: "/{mailboxId}/" + namespaceRoot: "/{mailboxId}/" \ No newline at end of file diff --git a/src/test/resources/jmscts/config/filter.xml b/src/test/resources/jmscts/config/filter.xml index 939ace6a8..7755c0e5a 100644 --- a/src/test/resources/jmscts/config/filter.xml +++ b/src/test/resources/jmscts/config/filter.xml @@ -3,7 +3,7 @@