diff --git a/.github/workflows/cdb-base-mqtt.yml b/.github/workflows/cdb-base-mqtt.yml new file mode 100644 index 000000000..f05712045 --- /dev/null +++ b/.github/workflows/cdb-base-mqtt.yml @@ -0,0 +1,45 @@ +name: CDB Base MQTT + +on: + push: + paths: + - "tools/developer_tools/cdb-base-mqtt/**" + - ".github/workflows/cdb-base-mqtt.yml" + pull_request: + paths: + - "tools/developer_tools/cdb-base-mqtt/**" + - ".github/workflows/cdb-base-mqtt.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: tools/developer_tools/cdb-base-mqtt + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 8 and Maven cache + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "8" + cache: maven + cache-dependency-path: tools/developer_tools/cdb-base-mqtt/pom.xml + + - name: Run tests + run: mvn --batch-mode --no-transfer-progress verify + + - name: Publish test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: cdb-base-mqtt-test-results + path: tools/developer_tools/cdb-base-mqtt/target/surefire-reports/ + if-no-files-found: warn diff --git a/CLAUDE.md b/CLAUDE.md index 1f2e905bf..3795faf01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,7 @@ MariaDB 10.5. Authoritative schema is the set of `create_cdb_tables.sql`, `creat - **Generated files that look like sources** are common: `cdb.portal.properties`, `web/WEB-INF/glassfish-web.xml`, `web/WEB-INF/web.xml`, `setup/glassfish-resources.xml`, `nbproject/private/private.properties`, and everything under `tools/developer_tools/python-client/packages/api/cdbApi/`. They are produced from `*.template` files (or via openapi-generator) by `dev-config` / `configure-web-portal` / `dist` targets and are in `.gitignore`. Edit the template, regenerate, then commit only the template. - **`cdb` vs `cdb_dev`**: every db, deploy, undeploy, and backup target has a `-dev` variant that targets a separate database name and Payara domain, so a developer can run both side-by-side. When adding new admin scripts, follow the same pattern (first arg is DB name, defaulting to `cdb`). - **Single canonical version string**: run `make prepare-release` (`sbin/cdb_prepare_release.py`) to bump `etc/version`, `openapi.yaml`, the web `?v=` cache-busters, the python-client `packages/*/pyproject.toml`/conda recipes, and scaffold `docs/release-notes/.md` together. It also flags any file that drifted out of sync in a prior manual release, and refreshes `uv.lock`. +- **Release notes as a plan milestone**: when planning a feature, include updating `docs/release-notes/.md` as the final milestone. Use the in-progress version (unreleased versions may already have notes; `etc/version` lags until `make prepare-release`), and create the file if it doesn't exist. Match the style of existing notes: `#` headings per area (e.g. `# MQTT`, `# API`, `# Testing`, `# Bug Fixes`), concise user-facing `*` bullets with nested sub-bullets for detail, and no commit hashes or file lists. Only list fixes to previously released behavior under Bug Fixes. - **Plugins** under `tools/developer_tools/cdb_plugins/plugins//` extend both the Java portal (deployed via `make deploy-cdb-plugin` → `update_plugin_generated_files.py`) and the Python web service. `pluginTemplates/` is the scaffold. - **REST changes touch three places**: the Java route, `openapi.yaml`, and (after `generatePyClient.sh`) the regenerated `cdbApi/` package. The Python API tests under `tools/developer_tools/python-client/test/` are the integration check. diff --git a/Makefile b/Makefile index f2daa3b76..33d369d64 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ TOP = . SUBDIRS = src .PHONY: support support-mysql dev-config prepare-release release-python-client +.PHONY: mqtt-configuration configure-mqtt mqtt-configuration-dev configure-mqtt-dev .PHONY: db backup db-dev deploy-web-portal undeploy-web-portal deploy-web-service undeploy-web-service .PHONY: db-dev backup-dev deploy-web-portal-dev undeploy-web-portal-dev deploy-web-service-dev undeploy-web-service-dev @@ -28,6 +29,12 @@ release-python-client: configuration: $(TOP)/sbin/cdb_create_configuration.sh +mqtt-configuration: + $(TOP)/sbin/cdb_create_mqtt_configuration.sh cdb + +configure-mqtt: + $(TOP)/sbin/cdb_configure_mqtt_service.sh cdb + support: $(TOP)/sbin/cdb_install_support.sh @@ -79,6 +86,12 @@ undeploy-web-service: configuration-dev: $(TOP)/sbin/cdb_create_configuration.sh cdb_dev +mqtt-configuration-dev: + $(TOP)/sbin/cdb_create_mqtt_configuration.sh cdb_dev + +configure-mqtt-dev: + $(TOP)/sbin/cdb_configure_mqtt_service.sh cdb_dev + db-dev: $(TOP)/sbin/cdb_create_db.sh cdb_dev diff --git a/docs/release-notes/3.20.0.md b/docs/release-notes/3.20.0.md new file mode 100644 index 000000000..8b148c4ee --- /dev/null +++ b/docs/release-notes/3.20.0.md @@ -0,0 +1,44 @@ +# MQTT + +* Added MQTT integration. The portal publishes change events to an MQTT broker so external systems (e.g. BELY) can react to CDB changes. + * Shared `cdb-base-mqtt` library for MQTT connection and event serialization, used by both BELY and CDB. + * Events include the affected entity, the user who triggered the change, and details of what changed. + * Consistent operation names across events (add, update, delete, trash, restore). +* Entity events + * Item add, update, and delete. +* Property events + * Property value add, update, and delete, with the before and after values. +* Log events + * Log entry add, update, and delete, with the before and after text. + * Log attachment add. +* Machine design events + * New machine design, including ones created from a template. + * Assigned item update. + * Move within the machine hierarchy. + * Represented element update, including changes made through a template. + * Trash and restore. +* Inventory events + * Location update. + * Assembly element assignment and clearing. +* Configuration + * `make mqtt-configuration` and `make configure-mqtt` (with `-dev` variants) to create and deploy the MQTT connection configuration. + +# API + +* Ability to update an existing log entry. +* Ability to delete a log entry. +* Attachments added via the API, utilities, and the portal now use the same workflow. + +# Testing + +* Added unit tests for all MQTT event models and change detection. +* Added an MQTT test framework that listens to the broker and verifies published events. + * Python API tests for events that can be triggered via the API. + * Selenium tests for events only available in the portal. + * MQTT tests are included in `make test`. +* CI runs the shared MQTT library tests when it changes. +* Documented the new tests in the python client and Selenium READMEs. + +# Bug Fixes + +* Destroy is now called after a successful `updateOnRemoval`, removing the need for workarounds in the log list views. diff --git a/sbin/cdb_configure_mqtt_service.sh b/sbin/cdb_configure_mqtt_service.sh new file mode 100755 index 000000000..020298bd0 --- /dev/null +++ b/sbin/cdb_configure_mqtt_service.sh @@ -0,0 +1,170 @@ +#!/bin/bash + +# Copyright (c) UChicago Argonne, LLC. All rights reserved. +# See LICENSE file. + + +# +# Script used for configuring MQTT connector for CDB webapp +# Deploys MQTT Resource Adapter and creates connection pool/resource +# +# Usage: +# +# $0 [CDB_DB_NAME] [mqtt_config_file] +# +# If no config file is specified, defaults to $CDB_INSTALL_DIR/etc/.mqtt.conf +# +# Sample MQTT configuration file contents (mqtt.conf): +# MQTT_HOST=localhost # MQTT broker hostname (default: localhost) +# MQTT_PORT=1883 # MQTT broker port (default: 1883) +# MQTT_USERNAME=admin # MQTT username (optional) +# MQTT_PASSWORD=admin # MQTT password (optional) +# MQTT_CLEAN_SESSION=true # Clean session flag (optional) +# MQTT_QOS=1 # Quality of Service level (optional) +# MQTT_KEEP_ALIVE_INTERVAL=60 # Keep alive interval in seconds (optional) +# MQTT_CONNECTION_TIMEOUT=30 # Connection timeout in seconds (optional) +# MQTT_MAX_INFLIGHT=10 # Maximum number of messages in flight (optional) +# MQTT_AUTOMATIC_RECONNECT=true # Automatic reconnection on disconnect (optional) +# MQTT_FILE_PERSISTANCE=false # Enable file-based message persistence (optional) +# MQTT_PERSISTENCE_DIRECTORY=. # Directory for persistent message storage (optional) +# MQTT_TOPIC_FILTER # MQTT topic filter for subscriptions (optional) + +MY_DIR=`dirname $0` && cd $MY_DIR && MY_DIR=`pwd` +if [ -z "${CDB_ROOT_DIR}" ]; then + CDB_ROOT_DIR=$MY_DIR/.. +fi +CDB_ENV_FILE=${CDB_ROOT_DIR}/setup.sh +if [ ! -f ${CDB_ENV_FILE} ]; then + echo "Environment file ${CDB_ENV_FILE} does not exist." + exit 2 +fi +. ${CDB_ENV_FILE} > /dev/null + +# Constants +MQTT_POOL_NAME="cdb/MQTT/pool" +MQTT_RESOURCE_NAME="cdb/MQTT/resource" +MQTT_RAR_NAME="mqtt-rar-0.8.0" +MQTT_CONNECTOR_RAR_DEPLOYMENT_NAME="cdb-mqtt-rar-deployment" +MQTT_RAR_PATH=$CDB_ROOT_DIR/src/lib/${MQTT_RAR_NAME}.rar + +# Look for the deployment-specific MQTT configuration file +CDB_DB_NAME=${1:-${CDB_DB_NAME:-cdb}} +if [ ! -z "$2" ]; then + mqttConfigFile=$2 +else + mqttConfigFile=$CDB_INSTALL_DIR/etc/${CDB_DB_NAME}.mqtt.conf +fi + +if [ -f $mqttConfigFile ]; then + echo "Using MQTT config file: $mqttConfigFile" + . $mqttConfigFile +else + echo "Error: MQTT config file $mqttConfigFile not found." + echo "You can create one using cdb_create_mqtt_configuration.sh ${CDB_DB_NAME}" + exit 1 +fi + +CDB_HOST_ARCH=$(uname -sm | tr -s '[:upper:][:blank:]' '[:lower:][\-]') +GLASSFISH_DIR=$CDB_SUPPORT_DIR/payara/$CDB_HOST_ARCH + +ASADMIN_CMD=$GLASSFISH_DIR/bin/asadmin + +# MQTT Configuration defaults +MQTT_HOST=${MQTT_HOST:=localhost} +MQTT_PORT=${MQTT_PORT:=1883} + +# Build properties string for connection pool +PROPERTIES="serverURIs=tcp\\://${MQTT_HOST}\\:${MQTT_PORT}" + +if [ ! -z "$MQTT_USERNAME" ]; then + PROPERTIES="${PROPERTIES}:userName=${MQTT_USERNAME}" +fi + +if [ ! -z "$MQTT_PASSWORD" ]; then + PROPERTIES="${PROPERTIES}:password=${MQTT_PASSWORD}" +fi + +if [ ! -z "$MQTT_CLEAN_SESSION" ]; then + PROPERTIES="${PROPERTIES}:cleanSession=${MQTT_CLEAN_SESSION}" +fi + +if [ ! -z "$MQTT_KEEP_ALIVE_INTERVAL" ]; then + PROPERTIES="${PROPERTIES}:keepAliveInterval=${MQTT_KEEP_ALIVE_INTERVAL}" +fi + +if [ ! -z "$MQTT_CONNECTION_TIMEOUT" ]; then + PROPERTIES="${PROPERTIES}:connectionTimeout=${MQTT_CONNECTION_TIMEOUT}" +fi + +if [ ! -z "$MQTT_MAX_INFLIGHT" ]; then + PROPERTIES="${PROPERTIES}:maxInflight=${MQTT_MAX_INFLIGHT}" +fi + +if [ ! -z "$MQTT_AUTOMATIC_RECONNECT" ]; then + PROPERTIES="${PROPERTIES}:automaticReconnect=${MQTT_AUTOMATIC_RECONNECT}" +fi + +if [ ! -z "$MQTT_FILE_PERSISTANCE" ]; then + PROPERTIES="${PROPERTIES}:filePersistance=${MQTT_FILE_PERSISTANCE}" +fi + +if [ ! -z "$MQTT_PERSISTENCE_DIRECTORY" ]; then + PROPERTIES="${PROPERTIES}:persistenceDirectory=${MQTT_PERSISTENCE_DIRECTORY}" +fi + +if [ ! -z "$MQTT_QOS" ]; then + PROPERTIES="${PROPERTIES}:qos=${MQTT_QOS}" +fi + +if [ ! -z "$MQTT_TOPIC_FILTER" ]; then + PROPERTIES="${PROPERTIES}:topicFilter=${MQTT_TOPIC_FILTER}" +fi + +# Deploy MQTT RAR +echo "Deploying MQTT RAR" +if [ -f "$MQTT_RAR_PATH" ]; then + # Check if already deployed and undeploy if needed + $ASADMIN_CMD list-applications | grep -q ${MQTT_CONNECTOR_RAR_DEPLOYMENT_NAME} && { + echo "Undeploying existing MQTT RAR" + # Check if resource exists and delete it + $ASADMIN_CMD list-connector-resources | grep -q ${MQTT_RESOURCE_NAME} && { + echo "Deleting existing MQTT resource" + $ASADMIN_CMD delete-connector-resource ${MQTT_RESOURCE_NAME} || exit 1 + } + + # Check if connection pool exists and delete it + $ASADMIN_CMD list-connector-connection-pools | grep -q ${MQTT_POOL_NAME} && { + echo "Deleting existing MQTT connection pool" + $ASADMIN_CMD delete-connector-connection-pool ${MQTT_POOL_NAME} || exit 1 + } + + echo "Undeploying existing MQTT RAR" + $ASADMIN_CMD undeploy ${MQTT_CONNECTOR_RAR_DEPLOYMENT_NAME} || exit 1 + } + $ASADMIN_CMD deploy --name ${MQTT_CONNECTOR_RAR_DEPLOYMENT_NAME} $MQTT_RAR_PATH || exit 1 +else + echo "Warning: MQTT RAR file not found at $MQTT_RAR_PATH" + exit 1 +fi + +# Create MQTT connection pool +echo "Creating MQTT connection pool ${MQTT_POOL_NAME}" +$ASADMIN_CMD create-connector-connection-pool \ + --raname ${MQTT_CONNECTOR_RAR_DEPLOYMENT_NAME} \ + --connectiondefinition fish.payara.cloud.connectors.mqtt.api.MQTTConnectionFactory \ + --property "${PROPERTIES}" \ + ${MQTT_POOL_NAME} || exit 1 +# Create MQTT resource +echo "Creating MQTT resource ${MQTT_RESOURCE_NAME}" +$ASADMIN_CMD create-connector-resource \ + --poolname ${MQTT_POOL_NAME} \ + ${MQTT_RESOURCE_NAME} || exit 1 + +# The connector logs allocation failures with a full stack trace before returning null. +$ASADMIN_CMD set-log-levels fish.payara.cloud.connectors.mqtt.api.outbound.MQTTConnectionFactoryImpl=OFF || exit 1 + +# Test MQTT connection pool +echo "Testing MQTT connection pool" +$ASADMIN_CMD ping-connection-pool ${MQTT_POOL_NAME} || { echo "Warning: MQTT connection pool ping failed"; exit 1; } + +echo "Restart or redeploy CDB." \ No newline at end of file diff --git a/sbin/cdb_create_mqtt_configuration.sh b/sbin/cdb_create_mqtt_configuration.sh new file mode 100755 index 000000000..000b0f4fb --- /dev/null +++ b/sbin/cdb_create_mqtt_configuration.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Script to create MQTT configuration file for CDB service +# Usage: $0 [CDB_DB_NAME] + +MY_DIR=`dirname $0` && cd $MY_DIR && MY_DIR=`pwd` +if [ -z "${CDB_ROOT_DIR}" ]; then + CDB_ROOT_DIR=$MY_DIR/.. +fi +CDB_ENV_FILE=${CDB_ROOT_DIR}/setup.sh +if [ ! -f ${CDB_ENV_FILE} ]; then + echo "Environment file ${CDB_ENV_FILE} does not exist." + exit 2 +fi +. ${CDB_ENV_FILE} > /dev/null + +# Default configuration file location +CDB_DB_NAME=${1:-${CDB_DB_NAME:-cdb}} +MQTT_CONFIG_FILE=${CDB_INSTALL_DIR}/etc/${CDB_DB_NAME}.mqtt.conf + +echo "===================================" +echo "MQTT Configuration Setup for CDB" +echo "===================================" +echo "" +echo "This script will help you create an MQTT configuration file." +echo "Configuration will be saved to: $MQTT_CONFIG_FILE" +echo "" + +# Check if config file already exists +if [ -f "$MQTT_CONFIG_FILE" ]; then + read -p "Configuration file already exists. Overwrite? (y/n): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Exiting without changes." + exit 0 + fi +fi + +echo "" +echo "Configuration Details:" +echo "- cleanSession: Whether client and server should remember state across reconnects" +echo "- automaticReconnect: Whether client will automatically reconnect if connection is lost" +echo "- filePersistance: Whether to use file persistence for un-acknowledged messages" +echo "- persistenceDirectory: Directory to use for file persistence" +echo "- connectionTimeout: Connection timeout value in seconds" +echo "- maxInflight: Maximum messages that can be sent without acknowledgements" +echo "- keepAliveInterval: Keep alive interval in seconds" +echo "- userName/password: Authentication credentials" +# Disable MDB only variables. +# echo "- topicFilter: Topic Filter (For MDBs only)" +# echo "- qos: Quality of Service for the subscription (For MDBs only)" +echo "" + +# Ensure directory exists +mkdir -p $(dirname "$MQTT_CONFIG_FILE") + +# Prompt for configuration values +read -p "MQTT Host [localhost]: " MQTT_HOST +MQTT_HOST=${MQTT_HOST:-localhost} + +read -p "MQTT Port [1883]: " MQTT_PORT +MQTT_PORT=${MQTT_PORT:-1883} + +read -p "MQTT Username (leave empty for no auth): " MQTT_USERNAME + +if [ ! -z "$MQTT_USERNAME" ]; then + read -s -p "MQTT Password: " MQTT_PASSWORD + echo +fi + +read -p "Clean Session (true/false) [false]: " CLEAN_SESSION +CLEAN_SESSION=${CLEAN_SESSION:-false} + +read -p "Automatic Reconnect (true/false) [true]: " AUTOMATIC_RECONNECT +AUTOMATIC_RECONNECT=${AUTOMATIC_RECONNECT:-true} + +read -p "File Persistance (true/false) [false]: " FILE_PERSISTANCE +FILE_PERSISTANCE=${FILE_PERSISTANCE:-false} + +read -p "Persistence Directory [.]: " PERSISTENCE_DIRECTORY +PERSISTENCE_DIRECTORY=${PERSISTENCE_DIRECTORY:-.} + +read -p "Connection Timeout (seconds) [30]: " CONNECTION_TIMEOUT +CONNECTION_TIMEOUT=${CONNECTION_TIMEOUT:-30} + +read -p "Max Inflight [10]: " MAX_INFLIGHT +MAX_INFLIGHT=${MAX_INFLIGHT:-10} + +read -p "Keep Alive Interval (seconds) [60]: " KEEP_ALIVE_INTERVAL +KEEP_ALIVE_INTERVAL=${KEEP_ALIVE_INTERVAL:-60} + +# MDB only variables +# read -p "Topic Filter (leave empty if not using MDB): " TOPIC_FILTER +# read -p "QoS (0/1/2) [0]: " QOS +# QOS=${QOS:-0} + +# Write configuration file +cat > "$MQTT_CONFIG_FILE" << EOF +# MQTT Configuration for CDB Service +# Generated on $(date) + +MQTT_HOST=$MQTT_HOST +MQTT_PORT=$MQTT_PORT +EOF + +if [ ! -z "$MQTT_USERNAME" ]; then + echo "MQTT_USERNAME=$MQTT_USERNAME" >> "$MQTT_CONFIG_FILE" +fi + +if [ ! -z "$MQTT_PASSWORD" ]; then + echo "MQTT_PASSWORD=$MQTT_PASSWORD" >> "$MQTT_CONFIG_FILE" +fi + +cat >> "$MQTT_CONFIG_FILE" << EOF +MQTT_CLEAN_SESSION=$CLEAN_SESSION +MQTT_AUTOMATIC_RECONNECT=$AUTOMATIC_RECONNECT +MQTT_FILE_PERSISTANCE=$FILE_PERSISTANCE +MQTT_PERSISTENCE_DIRECTORY=$PERSISTENCE_DIRECTORY +MQTT_CONNECTION_TIMEOUT=$CONNECTION_TIMEOUT +MQTT_MAX_INFLIGHT=$MAX_INFLIGHT +MQTT_KEEP_ALIVE_INTERVAL=$KEEP_ALIVE_INTERVAL +EOF + +if [ ! -z "$TOPIC_FILTER" ]; then + echo "MQTT_TOPIC_FILTER=$TOPIC_FILTER" >> "$MQTT_CONFIG_FILE" +fi + +if [ ! -z "$QOS" ]; then + echo "MQTT_QOS=$QOS" >> "$MQTT_CONFIG_FILE" +fi + +echo "" +echo "Configuration file created successfully at: $MQTT_CONFIG_FILE" +echo "" +echo "You can now run cdb_configure_mqtt_service.sh ${CDB_DB_NAME} to apply this configuration." \ No newline at end of file diff --git a/sbin/cdb_mqtt_test.sh b/sbin/cdb_mqtt_test.sh new file mode 100755 index 000000000..7c7b6d835 --- /dev/null +++ b/sbin/cdb_mqtt_test.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Copyright (c) UChicago Argonne, LLC. All rights reserved. +# See LICENSE file. + +# +# Runs the MQTT integration tests against a deployed portal and a running broker. +# +# Prerequisites (not handled by this script): +# - an MQTT broker reachable by both Payara and this shell +# - Payara connector resource cdb/MQTT/resource configured +# (sbin/cdb_create_mqtt_configuration.sh, sbin/cdb_configure_mqtt_service.sh) +# - portal deployed at http://localhost:8080/cdb using the test database +# - a Python environment with paho-mqtt (v2), pytest, the generated cdbApi +# client and, for the UI tests, selenium +# +# This script never provisions a broker, reconfigures Payara, replaces the +# database, builds, deploys or restarts services. +# +# Environment: +# CDB_TEST_MQTT=false skip all MQTT tests +# CDB_TEST_MQTT_HOST broker host (default localhost) +# CDB_TEST_MQTT_PORT broker port (default 1883) +# CDB_TEST_MQTT_USERNAME optional broker username +# CDB_TEST_MQTT_PASSWORD optional broker password +# CDB_TEST_MQTT_TIMEOUT seconds to wait for connect/messages (default 10) +# CDB_TEST_MQTT_SKIP_SELENIUM=true run only the API MQTT tests +# +# Usage: +# +# $0 [extra pytest args] +# + +MY_DIR=`dirname $0` && cd $MY_DIR && MY_DIR=`pwd` +CDB_DIST_DIR=`cd $MY_DIR/.. && pwd` + +if [ -z "${CDB_ROOT_DIR}" ]; then + if [ ! -f $CDB_DIST_DIR/setup.sh ]; then + echo "Environment file $CDB_DIST_DIR/setup.sh does not exist." + exit 2 + fi + . $CDB_DIST_DIR/setup.sh > /dev/null +fi + +API_CLIENT_PATH=$CDB_DIST_DIR/tools/developer_tools/python-client +SELENIUM_PATH=$CDB_DIST_DIR/tools/developer_tools/portal_testing/PythonSeleniumTest + +# Shared helper (mqtt_test_support) lives in the API test directory. +export PYTHONPATH="$API_CLIENT_PATH/packages/api:$API_CLIENT_PATH/packages/cli:$API_CLIENT_PATH/test${PYTHONPATH:+:$PYTHONPATH}" + +if ! command -v pytest > /dev/null; then + echo "pytest is not on PATH; activate the test environment first." + exit 2 +fi + +PYTHON_CMD=`command -v python || command -v python3` +if ! $PYTHON_CMD -c "import paho.mqtt" > /dev/null 2>&1; then + echo "Warning: paho-mqtt is not importable; MQTT tests will be skipped." +fi + +HEADER_TEXT='****************' +PRINTF_HEADER="\n\n$HEADER_TEXT Starting %s $HEADER_TEXT\n\n\n" + +result=0 + +printf "$PRINTF_HEADER" "API MQTT Tests" +cd $API_CLIENT_PATH +pytest test/mqtt_test.py -rs "$@" || result=1 + +skip_selenium=`echo "$CDB_TEST_MQTT_SKIP_SELENIUM" | tr "[:upper:]" "[:lower:]"` +if [[ "$skip_selenium" == "true" ]]; then + printf "\nSkipping Selenium MQTT tests (CDB_TEST_MQTT_SKIP_SELENIUM=true).\n" +elif ls $SELENIUM_PATH/tests/test_mqtt_*.py > /dev/null 2>&1; then + printf "$PRINTF_HEADER" "Selenium MQTT Tests" + cd $SELENIUM_PATH + # Serial on purpose: the UI test mutates shared machine-design state. + pytest tests/ -m mqtt -p no:xdist -rs "$@" || result=1 +fi + +exit $result diff --git a/sbin/cdb_test.sh b/sbin/cdb_test.sh index b64aad8b0..6ee4ffc53 100755 --- a/sbin/cdb_test.sh +++ b/sbin/cdb_test.sh @@ -61,7 +61,13 @@ printf "$PRINTF_HEADER" "Selenium" cd $CDB_DIST_DIR/tools/developer_tools/portal_testing/PythonSeleniumTest # Selenium Manager resolves and downloads a compatible driver when Chrome starts. -pytest tests/ -n auto --dist loadfile --reruns 1 --reruns-delay 1 -rR +# MQTT UI tests mutate shared state, so they are excluded here and run serially below. +pytest tests/ -n auto --dist loadfile --reruns 1 --reruns-delay 1 -rR -m "not mqtt" + +# API MQTT tests already ran with the API suite; both skip when CDB_TEST_MQTT=false +# or the broker is unreachable. +printf "$PRINTF_HEADER" "Selenium MQTT" +pytest tests/ -m mqtt -p no:xdist -rsR if [[ $backup_database == 1 ]]; then printf "\n\nRestore the original database from the backup? [Y/n] " diff --git a/src/java/CdbWebPortal/lib/cdb-base-mqtt-1.0.0.jar b/src/java/CdbWebPortal/lib/cdb-base-mqtt-1.0.0.jar new file mode 100644 index 000000000..04b3e761a Binary files /dev/null and b/src/java/CdbWebPortal/lib/cdb-base-mqtt-1.0.0.jar differ diff --git a/src/java/CdbWebPortal/lib/mqtt-jca-api-1.0.0.jar b/src/java/CdbWebPortal/lib/mqtt-jca-api-1.0.0.jar new file mode 100644 index 000000000..fa423699f Binary files /dev/null and b/src/java/CdbWebPortal/lib/mqtt-jca-api-1.0.0.jar differ diff --git a/src/java/CdbWebPortal/nbproject/build-impl.xml b/src/java/CdbWebPortal/nbproject/build-impl.xml index 2c394668c..5467c0d3e 100644 --- a/src/java/CdbWebPortal/nbproject/build-impl.xml +++ b/src/java/CdbWebPortal/nbproject/build-impl.xml @@ -1041,6 +1041,8 @@ exists or setup the property manually. For example like this: + + @@ -1104,6 +1106,8 @@ exists or setup the property manually. For example like this: + + diff --git a/src/java/CdbWebPortal/nbproject/genfiles.properties b/src/java/CdbWebPortal/nbproject/genfiles.properties index f3a7e4c63..c1e02b10e 100644 --- a/src/java/CdbWebPortal/nbproject/genfiles.properties +++ b/src/java/CdbWebPortal/nbproject/genfiles.properties @@ -1,10 +1,10 @@ -build.xml.data.CRC32=d5de4743 +build.xml.data.CRC32=9c7b4582 build.xml.script.CRC32=67492cbd build.xml.stylesheet.CRC32=1707db4f@1.94.0.1 # This file is used by a NetBeans-based IDE to track changes in generated files such as build-impl.xml. # Do not edit this file. You may delete it but then the IDE will never regenerate such files for you. -nbproject/build-impl.xml.data.CRC32=d5de4743 -nbproject/build-impl.xml.script.CRC32=8f60c160 +nbproject/build-impl.xml.data.CRC32=9c7b4582 +nbproject/build-impl.xml.script.CRC32=e1cf7847 nbproject/build-impl.xml.stylesheet.CRC32=334708a0@1.94.0.1 nbproject/rest-build.xml.data.CRC32=83e13ee9 nbproject/rest-build.xml.script.CRC32=0d1fb1b4 diff --git a/src/java/CdbWebPortal/nbproject/project.properties b/src/java/CdbWebPortal/nbproject/project.properties index 4206c7f64..34eea5b9f 100644 --- a/src/java/CdbWebPortal/nbproject/project.properties +++ b/src/java/CdbWebPortal/nbproject/project.properties @@ -42,6 +42,7 @@ file.reference.bcmail-jdk14-1.38.jar=lib/bcmail-jdk14-1.38.jar file.reference.bcprov-jdk14-1.38.jar=lib/bcprov-jdk14-1.38.jar file.reference.bctsp-jdk14-1.38.jar=lib/bctsp-jdk14-1.38.jar file.reference.classgraph-4.8.31.jar=lib/classgraph-4.8.31.jar +file.reference.cdb-base-mqtt-1.0.0.jar=lib/cdb-base-mqtt-1.0.0.jar file.reference.commons-lang3-3.11.jar=lib/commons-lang3-3.11.jar file.reference.commons-logging-1.2.jar=lib/commons-logging-1.2.jar file.reference.dm-api-3.3.1.jar=lib/dm-api-3.3.1.jar @@ -79,6 +80,7 @@ file.reference.libphonenumber-8.12.3.jar=lib/libphonenumber-8.12.3.jar file.reference.log4j-api-2.26.0.jar=lib/log4j-api-2.26.0.jar file.reference.log4j-core-2.26.0.jar=lib/log4j-core-2.26.0.jar file.reference.metadata-extractor-2.17.0.jar=lib/metadata-extractor-2.17.0.jar +file.reference.mqtt-jca-api-1.0.0.jar=lib/mqtt-jca-api-1.0.0.jar file.reference.omnifaces-3.10.1.jar-1=lib/omnifaces-3.10.1.jar file.reference.pdfbox-2.0.24.jar=lib/pdfbox-2.0.24.jar file.reference.poi-4.0.1.jar=lib/poi-4.0.1.jar @@ -169,7 +171,9 @@ javac.classpath=\ ${file.reference.flexmark-util-html-0.64.8.jar}:\ ${file.reference.flexmark-util-misc-0.64.8.jar}:\ ${file.reference.flexmark-util-sequence-0.64.8.jar}:\ - ${file.reference.flexmark-util-visitor-0.64.8.jar} + ${file.reference.flexmark-util-visitor-0.64.8.jar}:\ + ${file.reference.cdb-base-mqtt-1.0.0.jar}:\ + ${file.reference.mqtt-jca-api-1.0.0.jar} # Space-separated list of extra javac options javac.compilerargs= javac.debug=true diff --git a/src/java/CdbWebPortal/nbproject/project.xml b/src/java/CdbWebPortal/nbproject/project.xml index fe2ceeb99..553e82ac8 100644 --- a/src/java/CdbWebPortal/nbproject/project.xml +++ b/src/java/CdbWebPortal/nbproject/project.xml @@ -242,6 +242,14 @@ ${file.reference.flexmark-util-visitor-0.64.8.jar} WEB-INF/lib + + ${file.reference.cdb-base-mqtt-1.0.0.jar} + WEB-INF/lib + + + ${file.reference.mqtt-jca-api-1.0.0.jar} + WEB-INF/lib + diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbAttachmentSnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbAttachmentSnapshot.java new file mode 100644 index 000000000..a6bf9eaf0 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbAttachmentSnapshot.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; + +/** + * Scalar attachment metadata; file contents are never included. Identified by its unique + * stored name, which exists before the attachment row is saved (unlike its DB ID). + */ +public final class CdbAttachmentSnapshot { + + private final String name; + private final String originalFilename; + + public CdbAttachmentSnapshot(Attachment attachment) { + name = attachment.getName(); + originalFilename = attachment.getOriginalFilename(); + } + + public String getName() { return name; } + public String getOriginalFilename() { return originalFilename; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityEvent.java new file mode 100644 index 000000000..f206c9582 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.CdbEntity; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed entity operation. */ +public class CdbEntityEvent extends CdbMqttEvent { + + private final CdbMqttOperation operation; + private final String entityId; + private final String entityType; + private final String entityDescription; + + public CdbEntityEvent(CdbMqttTopic topic, CdbEntity entity, UserInfo triggeredByUser) { + super(topic, triggeredByUser); + if (entity == null) { + throw new IllegalArgumentException("entity is required"); + } + this.operation = CdbMqttOperation.valueOf(topic.name()); + Object id = entity.getId(); + this.entityId = id == null ? null : id.toString(); + this.entityType = entity.getClass().getSimpleName(); + this.entityDescription = entity.getSystemLogString(); + } + + public CdbMqttOperation getOperation() { + return operation; + } + + public String getEntityId() { + return entityId; + } + + public String getEntityType() { + return entityType; + } + + public String getEntityDescription() { + return entityDescription; + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityTypeSnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityTypeSnapshot.java new file mode 100644 index 000000000..ac4d9d523 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbEntityTypeSnapshot.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.EntityType; + +/** Scalar entity-type reference for event payloads. */ +public final class CdbEntityTypeSnapshot { + + private final Integer id; + private final String name; + + public CdbEntityTypeSnapshot(EntityType entityType) { + if (entityType == null) { + throw new IllegalArgumentException("entity type is required"); + } + id = entityType.getId(); + name = entityType.getName(); + } + + public Integer getId() { return id; } + public String getName() { return name; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEvent.java new file mode 100644 index 000000000..0fc86c8a2 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventoryBase; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed inventory assembly-element assignment. */ +public final class CdbInventoryAssemblyElementEvent extends CdbMqttEvent { + + private final CdbItemSnapshot inventoryAssembly; + private final CdbItemElementSnapshot assemblyElement; + private final CdbItemElementSnapshot catalogElement; + private final CdbItemSnapshot oldAssignedInventoryItem; + private final CdbItemSnapshot newAssignedInventoryItem; + + public CdbInventoryAssemblyElementEvent(ItemDomainInventoryBase inventoryAssembly, + ItemElement assemblyElement, Item oldAssignedInventoryItem, + Item newAssignedInventoryItem, UserInfo user) { + super(CdbMqttTopic.INVENTORY_ASSEMBLY_ELEMENT_UPDATE, user); + if (inventoryAssembly == null || assemblyElement == null) { + throw new IllegalArgumentException("inventory assembly and element are required"); + } + this.inventoryAssembly = new CdbItemSnapshot(inventoryAssembly); + this.assemblyElement = new CdbItemElementSnapshot(assemblyElement); + ItemElement sourceElement = assemblyElement.getDerivedFromItemElement(); + catalogElement = sourceElement == null ? null : new CdbItemElementSnapshot(sourceElement); + this.oldAssignedInventoryItem = oldAssignedInventoryItem == null + ? null : new CdbItemSnapshot(oldAssignedInventoryItem); + this.newAssignedInventoryItem = newAssignedInventoryItem == null + ? null : new CdbItemSnapshot(newAssignedInventoryItem); + } + + public CdbItemSnapshot getInventoryAssembly() { return inventoryAssembly; } + public CdbItemElementSnapshot getAssemblyElement() { return assemblyElement; } + public CdbItemElementSnapshot getCatalogElement() { return catalogElement; } + public CdbItemSnapshot getOldAssignedInventoryItem() { return oldAssignedInventoryItem; } + public CdbItemSnapshot getNewAssignedInventoryItem() { return newAssignedInventoryItem; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemElementSnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemElementSnapshot.java new file mode 100644 index 000000000..4562ee78e --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemElementSnapshot.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; + +/** Scalar item-element reference for event payloads. */ +public final class CdbItemElementSnapshot { + + private final String id; + private final String name; + private final CdbItemSnapshot parentItem; + private final CdbItemSnapshot containedItem; + + public CdbItemElementSnapshot(ItemElement element) { + if (element == null) { + throw new IllegalArgumentException("element is required"); + } + Object elementId = element.getId(); + id = elementId == null ? null : elementId.toString(); + name = element.getName(); + parentItem = element.getParentItem() == null + ? null : new CdbItemSnapshot(element.getParentItem()); + containedItem = element.getContainedItem() == null + ? null : new CdbItemSnapshot(element.getContainedItem()); + } + + public String getId() { return id; } + public String getName() { return name; } + public CdbItemSnapshot getParentItem() { return parentItem; } + public CdbItemSnapshot getContainedItem() { return containedItem; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEvent.java new file mode 100644 index 000000000..d13ff52cd --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEvent.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.LocatableItem; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed item-location change. */ +public final class CdbItemLocationEvent extends CdbMqttEvent { + + private final CdbItemSnapshot item; + private final CdbItemSnapshot oldLocation; + private final CdbItemSnapshot newLocation; + private final String oldLocationDetails; + private final String newLocationDetails; + + public CdbItemLocationEvent(LocatableItem item, + Item oldLocation, Item newLocation, + String oldLocationDetails, String newLocationDetails, UserInfo user) { + super(CdbMqttTopic.ITEM_LOCATION_UPDATE, user); + if (item == null) { + throw new IllegalArgumentException("item is required"); + } + this.item = new CdbItemSnapshot(item); + this.oldLocation = oldLocation == null ? null : new CdbItemSnapshot(oldLocation); + this.newLocation = newLocation == null ? null : new CdbItemSnapshot(newLocation); + this.oldLocationDetails = oldLocationDetails; + this.newLocationDetails = newLocationDetails; + } + + public CdbItemSnapshot getItem() { return item; } + public CdbItemSnapshot getOldLocation() { return oldLocation; } + public CdbItemSnapshot getNewLocation() { return newLocation; } + public String getOldLocationDetails() { return oldLocationDetails; } + public String getNewLocationDetails() { return newLocationDetails; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEvent.java new file mode 100644 index 000000000..f27e2ff5d --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEvent.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Attachment newly associated with an item log entry. */ +public final class CdbItemLogAttachmentEvent extends CdbMqttEvent { + + private final CdbMqttOperation operation = CdbMqttOperation.ADD; + private final String itemId; + private final String itemType; + private final String itemDescription; + private final String logId; + private final CdbAttachmentSnapshot attachment; + + public CdbItemLogAttachmentEvent(Item item, Log log, Attachment attachment, UserInfo user) { + super(CdbMqttTopic.ITEM_LOG_ATTACHMENT_ADD, user); + if (item == null || log == null || attachment == null) { + throw new IllegalArgumentException("item, log and attachment are required"); + } + Object id = item.getId(); + itemId = id == null ? null : id.toString(); + itemType = item.getClass().getSimpleName(); + itemDescription = item.getSystemLogString(); + logId = log.getId() == null ? null : log.getId().toString(); + this.attachment = new CdbAttachmentSnapshot(attachment); + } + + public CdbMqttOperation getOperation() { return operation; } + public String getItemId() { return itemId; } + public String getItemType() { return itemType; } + public String getItemDescription() { return itemDescription; } + public String getLogId() { return logId; } + public CdbAttachmentSnapshot getAttachment() { return attachment; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogEvent.java new file mode 100644 index 000000000..80fa55890 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemLogEvent.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.mqtt.utilities.CdbMqttTextUtility; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.Objects; + +/** + * Scalar snapshot of a completed item log-entry change. Uses CdbMqttTextUtility + * conventions: add/delete carry text bounded at MAX_TEXT_LENGTH; updates carry a + * bounded line diff (null when the text is unchanged) and omit full text. + */ +public final class CdbItemLogEvent extends CdbMqttEvent { + + public static final int MAX_TEXT_LENGTH = CdbMqttTextUtility.MAX_TEXT_LENGTH; + + private final CdbMqttOperation operation; + private final String itemId; + private final String itemType; + private final String itemDescription; + private final CdbLogSnapshot oldLog; + private final CdbLogSnapshot newLog; + private final String textDiff; + private final boolean diffTruncated; + + private CdbItemLogEvent(CdbMqttTopic topic, CdbMqttOperation operation, Item item, + Log oldLog, Log newLog, UserInfo user) { + super(topic, user); + if (item == null || (oldLog == null && newLog == null)) { + throw new IllegalArgumentException("item and a log entry are required"); + } + this.operation = operation; + Object id = item.getId(); + itemId = id == null ? null : id.toString(); + itemType = item.getClass().getSimpleName(); + itemDescription = item.getSystemLogString(); + + String oldText = oldLog == null ? null : oldLog.getText(); + String newText = newLog == null ? null : newLog.getText(); + if (oldLog != null && newLog != null) { + if (Objects.equals(oldText, newText)) { + textDiff = null; + diffTruncated = false; + } else { + String diff = CdbMqttTextUtility.createLineDiff(oldText, newText); + diffTruncated = CdbMqttTextUtility.exceedsLimit(diff); + textDiff = CdbMqttTextUtility.truncate(diff); + } + this.oldLog = new CdbLogSnapshot(oldLog, null, true); + this.newLog = new CdbLogSnapshot(newLog, null, true); + } else { + textDiff = null; + diffTruncated = CdbMqttTextUtility.exceedsLimit(oldText) + || CdbMqttTextUtility.exceedsLimit(newText); + this.oldLog = oldLog == null ? null + : new CdbLogSnapshot(oldLog, CdbMqttTextUtility.truncate(oldText), false); + this.newLog = newLog == null ? null + : new CdbLogSnapshot(newLog, CdbMqttTextUtility.truncate(newText), false); + } + } + + public static CdbItemLogEvent added(Item item, Log log, UserInfo user) { + return new CdbItemLogEvent(CdbMqttTopic.ITEM_LOG_ADD, CdbMqttOperation.ADD, + item, null, log, user); + } + + public static CdbItemLogEvent updated(Item item, Log oldLog, Log newLog, UserInfo user) { + return new CdbItemLogEvent(CdbMqttTopic.ITEM_LOG_UPDATE, CdbMqttOperation.UPDATE, + item, oldLog, newLog, user); + } + + public static CdbItemLogEvent deleted(Item item, Log log, UserInfo user) { + return new CdbItemLogEvent(CdbMqttTopic.ITEM_LOG_DELETE, CdbMqttOperation.DELETE, + item, log, null, user); + } + + public CdbMqttOperation getOperation() { return operation; } + public String getItemId() { return itemId; } + public String getItemType() { return itemType; } + public String getItemDescription() { return itemDescription; } + public CdbLogSnapshot getOldLog() { return oldLog; } + public CdbLogSnapshot getNewLog() { return newLog; } + public String getTextDiff() { return textDiff; } + public boolean isDiffTruncated() { return diffTruncated; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEvent.java new file mode 100644 index 000000000..120dd7f68 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEvent.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.mqtt.utilities.CdbMqttTextUtility; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyValue; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed item-property change. */ +public final class CdbItemPropertyEvent extends CdbMqttEvent { + + public static final int MAX_TEXT_LENGTH = CdbMqttTextUtility.MAX_TEXT_LENGTH; + + private final CdbMqttOperation operation; + private final String itemId; + private final String itemType; + private final String itemDescription; + private final CdbPropertySnapshot oldProperty; + private final CdbPropertySnapshot newProperty; + private final String textDiff; + private final boolean diffTruncated; + + private CdbItemPropertyEvent(CdbMqttTopic topic, Item item, + PropertyValue oldProperty, PropertyValue newProperty, UserInfo user) { + super(topic, user); + if (item == null || (oldProperty == null && newProperty == null)) { + throw new IllegalArgumentException("item and a property value are required"); + } + operation = operationFor(topic); + Object id = item.getId(); + itemId = id == null ? null : id.toString(); + itemType = item.getClass().getSimpleName(); + itemDescription = item.getSystemLogString(); + + boolean longText = (oldProperty != null && CdbPropertySnapshot.isLongText(oldProperty)) + || (newProperty != null && CdbPropertySnapshot.isLongText(newProperty)); + String oldText = oldProperty == null ? null : oldProperty.getText(); + String newText = newProperty == null ? null : newProperty.getText(); + if (longText && oldProperty != null && newProperty != null) { + String diff = CdbMqttTextUtility.createLineDiff(oldText, newText); + diffTruncated = CdbMqttTextUtility.exceedsLimit(diff); + textDiff = CdbMqttTextUtility.truncate(diff); + this.oldProperty = new CdbPropertySnapshot(oldProperty, null, true); + this.newProperty = new CdbPropertySnapshot(newProperty, null, true); + } else { + String boundedOldText = CdbMqttTextUtility.truncate(oldText); + String boundedNewText = CdbMqttTextUtility.truncate(newText); + diffTruncated = CdbMqttTextUtility.exceedsLimit(oldText) + || CdbMqttTextUtility.exceedsLimit(newText); + textDiff = null; + this.oldProperty = oldProperty == null ? null + : new CdbPropertySnapshot(oldProperty, boundedOldText, false); + this.newProperty = newProperty == null ? null + : new CdbPropertySnapshot(newProperty, boundedNewText, false); + } + } + + public static CdbItemPropertyEvent added(Item item, PropertyValue property, UserInfo user) { + return new CdbItemPropertyEvent(CdbMqttTopic.ITEM_PROPERTY_ADD, item, null, property, user); + } + + public static CdbItemPropertyEvent updated(Item item, PropertyValue oldProperty, + PropertyValue newProperty, UserInfo user) { + return new CdbItemPropertyEvent(CdbMqttTopic.ITEM_PROPERTY_UPDATE, + item, oldProperty, newProperty, user); + } + + public static CdbItemPropertyEvent deleted(Item item, PropertyValue property, UserInfo user) { + return new CdbItemPropertyEvent(CdbMqttTopic.ITEM_PROPERTY_DELETE, item, property, null, user); + } + + private static CdbMqttOperation operationFor(CdbMqttTopic topic) { + switch (topic) { + case ITEM_PROPERTY_ADD: return CdbMqttOperation.ADD; + case ITEM_PROPERTY_UPDATE: return CdbMqttOperation.UPDATE; + case ITEM_PROPERTY_DELETE: return CdbMqttOperation.DELETE; + default: throw new IllegalArgumentException("Invalid property event topic"); + } + } + + public CdbMqttOperation getOperation() { return operation; } + public String getItemId() { return itemId; } + public String getItemType() { return itemType; } + public String getItemDescription() { return itemDescription; } + public CdbPropertySnapshot getOldProperty() { return oldProperty; } + public CdbPropertySnapshot getNewProperty() { return newProperty; } + public String getTextDiff() { return textDiff; } + public boolean isDiffTruncated() { return diffTruncated; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemSnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemSnapshot.java new file mode 100644 index 000000000..b6f699a9a --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbItemSnapshot.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gov.anl.aps.cdb.portal.model.db.entities.EntityType; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** Scalar item reference for event payloads. */ +public final class CdbItemSnapshot { + + private final String id; + private final String domain; + private final Integer domainId; + private final String description; + private final List entityTypes; + + public CdbItemSnapshot(Item item) { + if (item == null) { + throw new IllegalArgumentException("item is required"); + } + Object itemId = item.getId(); + id = itemId == null ? null : itemId.toString(); + domain = item.getDomain() == null ? null : item.getDomain().getName(); + domainId = item.getDomain() == null ? null : item.getDomain().getId(); + description = item.getSystemLogString(); + List snapshots = new ArrayList<>(); + if (item.getEntityTypeList() != null) { + List entityTypes = new ArrayList<>(item.getEntityTypeList()); + entityTypes.sort(Comparator.comparing(EntityType::getId, + Comparator.nullsLast(Comparator.naturalOrder()))); + for (EntityType entityType : entityTypes) { + snapshots.add(new CdbEntityTypeSnapshot(entityType)); + } + } + entityTypes = Collections.unmodifiableList(snapshots); + } + + public String getId() { return id; } + public String getDomain() { return domain; } + @JsonProperty("domain_id") + public Integer getDomainId() { return domainId; } + public String getDescription() { return description; } + public List getEntityTypes() { return entityTypes; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbLogSnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbLogSnapshot.java new file mode 100644 index 000000000..525f365a9 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbLogSnapshot.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.annotation.JsonFormat; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.LogTopic; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.Date; + +/** Scalar snapshot of an item log entry; attachments are never included. */ +public final class CdbLogSnapshot { + + private final String logId; + private final String text; + private final boolean textOmitted; + private final Date enteredOnDateTime; + private final Date effectiveFromDateTime; + private final Date effectiveToDateTime; + private final String enteredByUsername; + private final String logTopicId; + private final String logTopicName; + + public CdbLogSnapshot(Log log, String text, boolean textOmitted) { + Object id = log.getId(); + logId = id == null ? null : id.toString(); + this.text = text; + this.textOmitted = textOmitted; + enteredOnDateTime = copy(log.getEnteredOnDateTime()); + effectiveFromDateTime = copy(log.getEffectiveFromDateTime()); + effectiveToDateTime = copy(log.getEffectiveToDateTime()); + UserInfo enteredBy = log.getEnteredByUser(); + enteredByUsername = enteredBy == null ? null : enteredBy.getUsername(); + LogTopic topic = log.getLogTopic(); + logTopicId = topic == null || topic.getId() == null ? null : topic.getId().toString(); + logTopicName = topic == null ? null : topic.getName(); + } + + private static Date copy(Date value) { + return value == null ? null : new Date(value.getTime()); + } + + public String getLogId() { return logId; } + public String getText() { return text; } + public boolean isTextOmitted() { return textOmitted; } + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Date getEnteredOnDateTime() { return copy(enteredOnDateTime); } + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Date getEffectiveFromDateTime() { return copy(effectiveFromDateTime); } + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Date getEffectiveToDateTime() { return copy(effectiveToDateTime); } + public String getEnteredByUsername() { return enteredByUsername; } + public String getLogTopicId() { return logTopicId; } + public String getLogTopicName() { return logTopicName; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEvent.java new file mode 100644 index 000000000..9ac9fa695 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEvent.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.annotation.JsonInclude; +import gov.anl.aps.cdb.mqtt.metadata.MachineAddChange; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** + * Scalar snapshot of a newly persisted machine-design node. Template fields are + * omitted unless the node was cloned from a template. + */ +public final class CdbMachineAddEvent extends CdbMqttEvent { + + private final CdbItemSnapshot machine; + private final CdbItemSnapshot parent; + @JsonInclude(JsonInclude.Include.NON_NULL) + private final CdbItemSnapshot sourceTemplate; + // New top node of the instantiation, or an existing machine a template was assigned to. + @JsonInclude(JsonInclude.Include.NON_NULL) + private final CdbItemSnapshot templateAppliedToMachine; + // Add events published for the same template application. + @JsonInclude(JsonInclude.Include.NON_NULL) + private final Integer templateCreatedMachineCount; + + public CdbMachineAddEvent(ItemDomainMachineDesign machine, + ItemDomainMachineDesign parent, UserInfo user) { + this(new MachineAddChange(machine, parent), user); + } + + public CdbMachineAddEvent(MachineAddChange change, UserInfo user) { + super(CdbMqttTopic.MACHINE_DESIGN_ADD, user); + if (change == null || change.getMachine() == null) { + throw new IllegalArgumentException("machine is required"); + } + this.machine = new CdbItemSnapshot(change.getMachine()); + this.parent = snapshot(change.getParent()); + this.sourceTemplate = snapshot(change.getSourceTemplate()); + this.templateAppliedToMachine = snapshot(change.getTemplateAppliedToMachine()); + this.templateCreatedMachineCount = change.getTemplateCreatedMachineCount(); + } + + private static CdbItemSnapshot snapshot(ItemDomainMachineDesign item) { + return item == null ? null : new CdbItemSnapshot(item); + } + + public CdbItemSnapshot getMachine() { return machine; } + public CdbItemSnapshot getParent() { return parent; } + public CdbItemSnapshot getSourceTemplate() { return sourceTemplate; } + public CdbItemSnapshot getTemplateAppliedToMachine() { return templateAppliedToMachine; } + public Integer getTemplateCreatedMachineCount() { return templateCreatedMachineCount; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEvent.java new file mode 100644 index 000000000..ce0cae98c --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEvent.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed machine assigned-item change. */ +public final class CdbMachineAssignedItemEvent extends CdbMqttEvent { + + private final CdbItemSnapshot machine; + private final CdbItemSnapshot oldAssignedItem; + private final CdbItemSnapshot newAssignedItem; + private final boolean oldInstalled; + private final boolean newInstalled; + + public CdbMachineAssignedItemEvent(ItemDomainMachineDesign machine, + Item oldAssignedItem, Item newAssignedItem, + boolean oldInstalled, boolean newInstalled, UserInfo user) { + super(CdbMqttTopic.MACHINE_DESIGN_ASSIGNED_ITEM_UPDATE, user); + if (machine == null) { + throw new IllegalArgumentException("machine is required"); + } + this.machine = new CdbItemSnapshot(machine); + this.oldAssignedItem = oldAssignedItem == null ? null : new CdbItemSnapshot(oldAssignedItem); + this.newAssignedItem = newAssignedItem == null ? null : new CdbItemSnapshot(newAssignedItem); + this.oldInstalled = oldInstalled; + this.newInstalled = newInstalled; + } + + public CdbItemSnapshot getMachine() { return machine; } + public CdbItemSnapshot getOldAssignedItem() { return oldAssignedItem; } + public CdbItemSnapshot getNewAssignedItem() { return newAssignedItem; } + public boolean isOldInstalled() { return oldInstalled; } + public boolean isNewInstalled() { return newInstalled; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEvent.java new file mode 100644 index 000000000..65fed6523 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEvent.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed machine move. */ +public final class CdbMachineMoveEvent extends CdbMqttEvent { + + private final CdbItemSnapshot machine; + private final CdbItemSnapshot oldParent; + private final CdbItemSnapshot newParent; + + public CdbMachineMoveEvent(ItemDomainMachineDesign machine, + ItemDomainMachineDesign oldParent, ItemDomainMachineDesign newParent, + UserInfo user) { + super(CdbMqttTopic.MACHINE_DESIGN_MOVE, user); + if (machine == null || newParent == null) { + throw new IllegalArgumentException("machine and new parent are required"); + } + this.machine = new CdbItemSnapshot(machine); + this.oldParent = oldParent == null ? null : new CdbItemSnapshot(oldParent); + this.newParent = new CdbItemSnapshot(newParent); + } + + public CdbItemSnapshot getMachine() { return machine; } + public CdbItemSnapshot getOldParent() { return oldParent; } + public CdbItemSnapshot getNewParent() { return newParent; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEvent.java new file mode 100644 index 000000000..086926b93 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEvent.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed represented-element change. */ +public final class CdbMachineRepresentedElementEvent extends CdbMqttEvent { + + private final CdbItemSnapshot machine; + private final CdbItemElementSnapshot oldRepresentedElement; + private final CdbItemElementSnapshot newRepresentedElement; + private final CdbItemSnapshot sourceTemplate; + + public CdbMachineRepresentedElementEvent(ItemDomainMachineDesign machine, + ItemElement oldRepresentedElement, ItemElement newRepresentedElement, + UserInfo user) { + this(machine, oldRepresentedElement, newRepresentedElement, null, user); + } + + public CdbMachineRepresentedElementEvent(ItemDomainMachineDesign machine, + ItemElement oldRepresentedElement, ItemElement newRepresentedElement, + ItemDomainMachineDesign sourceTemplate, UserInfo user) { + super(CdbMqttTopic.MACHINE_DESIGN_REPRESENTED_ELEMENT_UPDATE, user); + if (machine == null) { + throw new IllegalArgumentException("machine is required"); + } + this.machine = new CdbItemSnapshot(machine); + this.oldRepresentedElement = oldRepresentedElement == null + ? null : new CdbItemElementSnapshot(oldRepresentedElement); + this.newRepresentedElement = newRepresentedElement == null + ? null : new CdbItemElementSnapshot(newRepresentedElement); + this.sourceTemplate = sourceTemplate == null ? null : new CdbItemSnapshot(sourceTemplate); + } + + public CdbItemSnapshot getMachine() { return machine; } + public CdbItemElementSnapshot getOldRepresentedElement() { return oldRepresentedElement; } + public CdbItemElementSnapshot getNewRepresentedElement() { return newRepresentedElement; } + public CdbItemSnapshot getSourceTemplate() { return sourceTemplate; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEvent.java new file mode 100644 index 000000000..bf5be2225 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEvent.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Scalar snapshot of a completed machine trash or restore operation. */ +public final class CdbMachineTrashEvent extends CdbMqttEvent { + + private final CdbMqttOperation operation; + private final CdbItemSnapshot affectedMachine; + private final CdbItemSnapshot requestedHierarchyRoot; + private final int affectedMachineCount; + + public CdbMachineTrashEvent(CdbMqttTopic topic, ItemDomainMachineDesign machine, + ItemDomainMachineDesign requestedRoot, int affectedCount, UserInfo user) { + super(topic, user); + if (topic != CdbMqttTopic.MACHINE_DESIGN_TRASH + && topic != CdbMqttTopic.MACHINE_DESIGN_RESTORE) { + throw new IllegalArgumentException("trash or restore topic is required"); + } + if (machine == null || requestedRoot == null || affectedCount < 1) { + throw new IllegalArgumentException("machine, root, and affected count are required"); + } + operation = topic == CdbMqttTopic.MACHINE_DESIGN_TRASH + ? CdbMqttOperation.TRASH : CdbMqttOperation.RESTORE; + this.affectedMachine = new CdbItemSnapshot(machine); + this.requestedHierarchyRoot = new CdbItemSnapshot(requestedRoot); + this.affectedMachineCount = affectedCount; + } + + public CdbMqttOperation getOperation() { return operation; } + public CdbItemSnapshot getAffectedMachine() { return affectedMachine; } + public CdbItemSnapshot getRequestedHierarchyRoot() { return requestedHierarchyRoot; } + public int getAffectedMachineCount() { return affectedMachineCount; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttEvent.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttEvent.java new file mode 100644 index 000000000..9e7d3fa34 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttEvent.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; + +/** Base for CDB MQTT events with common routing and actor context. */ +public abstract class CdbMqttEvent extends MqttEvent { + + private final String topic; + private final String triggeredByUsername; + + protected CdbMqttEvent(CdbMqttTopic topic, UserInfo triggeredByUser) { + if (topic == null) { + throw new IllegalArgumentException("topic is required"); + } + this.topic = topic.getValue(); + triggeredByUsername = triggeredByUser == null ? null : triggeredByUser.getUsername(); + } + + @Override + public final String getTopic() { + return topic; + } + + public final String getTriggeredByUsername() { + return triggeredByUsername; + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttOperation.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttOperation.java new file mode 100644 index 000000000..bf4246645 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttOperation.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** Operation represented by a CDB MQTT event. */ +public enum CdbMqttOperation { + ADD("add"), + UPDATE("update"), + DELETE("delete"), + TRASH("trash"), + RESTORE("restore"); + + private final String value; + + CdbMqttOperation(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttPublisher.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttPublisher.java new file mode 100644 index 000000000..f2bd5f135 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttPublisher.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.base.mqtt.MqttPublisher; +import gov.anl.aps.cdb.base.mqtt.exception.MqttException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttNotConfiguredException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttPublishException; +import gov.anl.aps.cdb.base.mqtt.jca.JcaMqttPublisher; +import gov.anl.aps.cdb.base.mqtt.jca.JndiMqttConnectionProvider; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** Application adapter that makes MQTT publishing best effort. */ +public final class CdbMqttPublisher { + + public static final String JNDI_RESOURCE_NAME = "cdb/MQTT/resource"; + private static final Logger LOGGER = LogManager.getLogger(CdbMqttPublisher.class.getName()); + private static final MqttPublisher PUBLISHER = new JcaMqttPublisher( + new JndiMqttConnectionProvider(JNDI_RESOURCE_NAME), new ObjectMapper()); + + private CdbMqttPublisher() { + } + + public static void publish(MqttEvent event) { + try { + PUBLISHER.publish(event); + } catch (MqttNotConfiguredException ex) { + LOGGER.warn("MQTT is unavailable; skipped event for " + event.getTopic() + + ". " + ex.getMessage()); + } catch (MqttPublishException ex) { + LOGGER.warn("MQTT broker is unavailable or disconnected; skipped event for " + + event.getTopic() + ". " + getRootCauseMessage(ex)); + } catch (MqttException | RuntimeException ex) { + LOGGER.error("Failed to prepare MQTT event for " + event.getTopic(), ex); + } + } + + private static String getRootCauseMessage(Throwable error) { + Throwable cause = error; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage(); + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttTopic.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttTopic.java new file mode 100644 index 000000000..067fbb707 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbMqttTopic.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +public enum CdbMqttTopic { + ADD("cdb/entity/add"), + UPDATE("cdb/entity/update"), + DELETE("cdb/entity/delete"), + ITEM_PROPERTY_ADD("cdb/item/property/add"), + ITEM_PROPERTY_UPDATE("cdb/item/property/update"), + ITEM_PROPERTY_DELETE("cdb/item/property/delete"), + ITEM_LOG_ADD("cdb/item/log/add"), + ITEM_LOG_UPDATE("cdb/item/log/update"), + ITEM_LOG_DELETE("cdb/item/log/delete"), + ITEM_LOG_ATTACHMENT_ADD("cdb/item/log/attachment/add"), + MACHINE_DESIGN_ADD("cdb/machine-design/add"), + MACHINE_DESIGN_ASSIGNED_ITEM_UPDATE("cdb/machine-design/assigned-item/update"), + MACHINE_DESIGN_MOVE("cdb/machine-design/move"), + MACHINE_DESIGN_REPRESENTED_ELEMENT_UPDATE("cdb/machine-design/represented-element/update"), + MACHINE_DESIGN_TRASH("cdb/machine-design/trash"), + MACHINE_DESIGN_RESTORE("cdb/machine-design/restore"), + ITEM_LOCATION_UPDATE("cdb/item/location/update"), + INVENTORY_ASSEMBLY_ELEMENT_UPDATE("cdb/inventory/assembly-element/update"); + + private final String value; + + CdbMqttTopic(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbPropertySnapshot.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbPropertySnapshot.java new file mode 100644 index 000000000..4a7c08c06 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/CdbPropertySnapshot.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import gov.anl.aps.cdb.portal.model.db.entities.PropertyMetadata; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyType; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyTypeHandler; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyValue; +import gov.anl.aps.cdb.portal.model.jsf.handlers.MarkdownPropertyTypeHandler; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +/** Scalar snapshot of a property value. */ +public final class CdbPropertySnapshot { + + private final String propertyValueId; + private final String propertyTypeId; + private final String propertyTypeName; + private final String tag; + private final String value; + private final String text; + private final String units; + private final String description; + private final Map metadata; + private final boolean textOmitted; + + public CdbPropertySnapshot(PropertyValue propertyValue, String text, boolean textOmitted) { + Object id = propertyValue.getId(); + propertyValueId = id == null ? null : id.toString(); + PropertyType propertyType = propertyValue.getPropertyType(); + propertyTypeId = propertyType == null || propertyType.getId() == null + ? null : propertyType.getId().toString(); + propertyTypeName = propertyType == null ? null : propertyType.getName(); + tag = propertyValue.getTag(); + value = propertyValue.getValue(); + this.text = text; + units = propertyValue.getUnits(); + description = propertyValue.getDescription(); + metadata = snapshotMetadata(propertyValue); + this.textOmitted = textOmitted; + } + + public static boolean isLongText(PropertyValue propertyValue) { + if (propertyValue.getText() != null) { + return true; + } + PropertyType propertyType = propertyValue.getPropertyType(); + PropertyTypeHandler handler = propertyType == null + ? null : propertyType.getPropertyTypeHandler(); + return handler != null && MarkdownPropertyTypeHandler.HANDLER_NAME.equals(handler.getName()); + } + + private static Map snapshotMetadata(PropertyValue propertyValue) { + Map result = new TreeMap<>(); + if (propertyValue.getPropertyMetadataList() != null) { + for (PropertyMetadata propertyMetadata : propertyValue.getPropertyMetadataList()) { + result.put(propertyMetadata.getMetadataKey(), propertyMetadata.getMetadataValue()); + } + } + return Collections.unmodifiableMap(result); + } + + public String getPropertyValueId() { return propertyValueId; } + public String getPropertyTypeId() { return propertyTypeId; } + public String getPropertyTypeName() { return propertyTypeName; } + public String getTag() { return tag; } + public String getValue() { return value; } + public String getText() { return text; } + public String getUnits() { return units; } + public String getDescription() { return description; } + public Map getMetadata() { return metadata; } + public boolean isTextOmitted() { return textOmitted; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/InventoryAssemblyElementChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/InventoryAssemblyElementChange.java new file mode 100644 index 000000000..face2d4d3 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/InventoryAssemblyElementChange.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; + +/** Pending pre-update state for an inventory assembly-element assignment. */ +public final class InventoryAssemblyElementChange { + + private final ItemElement assemblyElement; + private final Item oldAssignedInventoryItem; + private final Item newAssignedInventoryItem; + + public InventoryAssemblyElementChange(ItemElement assemblyElement, + Item oldAssignedInventoryItem, Item newAssignedInventoryItem) { + this.assemblyElement = assemblyElement; + this.oldAssignedInventoryItem = oldAssignedInventoryItem; + this.newAssignedInventoryItem = newAssignedInventoryItem; + } + + public ItemElement getAssemblyElement() { return assemblyElement; } + public Item getOldAssignedInventoryItem() { return oldAssignedInventoryItem; } + public Item getNewAssignedInventoryItem() { return newAssignedInventoryItem; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLocationChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLocationChange.java new file mode 100644 index 000000000..2cef9b216 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLocationChange.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; + +/** Pending pre-update state for an item-location change. */ +public final class ItemLocationChange { + + private final Item oldLocation; + private final String oldLocationDetails; + private final Item newLocation; + private final String newLocationDetails; + + public ItemLocationChange(Item oldLocation, String oldLocationDetails, + Item newLocation, String newLocationDetails) { + this.oldLocation = oldLocation; + this.oldLocationDetails = oldLocationDetails; + this.newLocation = newLocation; + this.newLocationDetails = newLocationDetails; + } + + public Item getOldLocation() { return oldLocation; } + public String getOldLocationDetails() { return oldLocationDetails; } + public Item getNewLocation() { return newLocation; } + public String getNewLocationDetails() { return newLocationDetails; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLogChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLogChange.java new file mode 100644 index 000000000..64f99c642 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/ItemLogChange.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.Log; + +/** Pending pre-update state for an item log-entry update or delete. */ +public final class ItemLogChange { + + public enum Type { UPDATE, DELETE } + + private final Type type; + private final Log oldLog; + private final Log newLog; + + private ItemLogChange(Type type, Log oldLog, Log newLog) { + this.type = type; + this.oldLog = oldLog; + this.newLog = newLog; + } + + public static ItemLogChange updated(Log oldLog, Log newLog) { + return new ItemLogChange(Type.UPDATE, oldLog, newLog); + } + + public static ItemLogChange deleted(Log oldLog) { + return new ItemLogChange(Type.DELETE, oldLog, null); + } + + public Type getType() { return type; } + public Log getOldLog() { return oldLog; } + public Log getNewLog() { return newLog; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/LogAttachmentChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/LogAttachmentChange.java new file mode 100644 index 000000000..58387cdd3 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/LogAttachmentChange.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.Log; + +/** Pending attachment newly associated with an item log; IDs may be assigned on save. */ +public final class LogAttachmentChange { + + private final Log log; + private final Attachment attachment; + + public LogAttachmentChange(Log log, Attachment attachment) { + this.log = log; + this.attachment = attachment; + } + + public Log getLog() { return log; } + public Attachment getAttachment() { return attachment; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAddChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAddChange.java new file mode 100644 index 000000000..4b289afdb --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAddChange.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; + +/** Pending pre-persistence context for a machine node that does not yet exist in the DB. */ +public final class MachineAddChange { + + private final ItemDomainMachineDesign machine; + private final ItemDomainMachineDesign parent; + private final ItemDomainMachineDesign sourceTemplate; + private final ItemDomainMachineDesign templateAppliedToMachine; + private final Integer templateCreatedMachineCount; + + public MachineAddChange(ItemDomainMachineDesign machine, ItemDomainMachineDesign parent) { + this(machine, parent, null, null, null); + } + + public MachineAddChange(ItemDomainMachineDesign machine, ItemDomainMachineDesign parent, + ItemDomainMachineDesign sourceTemplate, + ItemDomainMachineDesign templateAppliedToMachine, + Integer templateCreatedMachineCount) { + this.machine = machine; + this.parent = parent; + this.sourceTemplate = sourceTemplate; + this.templateAppliedToMachine = templateAppliedToMachine; + this.templateCreatedMachineCount = templateCreatedMachineCount; + } + + public ItemDomainMachineDesign getMachine() { return machine; } + public ItemDomainMachineDesign getParent() { return parent; } + public ItemDomainMachineDesign getSourceTemplate() { return sourceTemplate; } + public ItemDomainMachineDesign getTemplateAppliedToMachine() { return templateAppliedToMachine; } + public Integer getTemplateCreatedMachineCount() { return templateCreatedMachineCount; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAssignedItemChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAssignedItemChange.java new file mode 100644 index 000000000..12fc30228 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineAssignedItemChange.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.Item; + +/** Pending pre-update state for a machine assigned-item change. */ +public final class MachineAssignedItemChange { + + private final Item oldAssignedItem; + private final boolean oldInstalled; + + public MachineAssignedItemChange(Item oldAssignedItem, boolean oldInstalled) { + this.oldAssignedItem = oldAssignedItem; + this.oldInstalled = oldInstalled; + } + + public Item getOldAssignedItem() { return oldAssignedItem; } + public boolean isOldInstalled() { return oldInstalled; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineRepresentedElementChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineRepresentedElementChange.java new file mode 100644 index 000000000..e5453b478 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineRepresentedElementChange.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; + +/** Pending pre-update state for a machine represented-element change. */ +public final class MachineRepresentedElementChange { + + private final ItemElement oldRepresentedElement; + private final ItemDomainMachineDesign sourceTemplate; + + public MachineRepresentedElementChange(ItemElement oldRepresentedElement) { + this(oldRepresentedElement, null); + } + + public MachineRepresentedElementChange(ItemElement oldRepresentedElement, + ItemDomainMachineDesign sourceTemplate) { + this.oldRepresentedElement = oldRepresentedElement; + this.sourceTemplate = sourceTemplate; + } + + public ItemElement getOldRepresentedElement() { return oldRepresentedElement; } + public ItemDomainMachineDesign getSourceTemplate() { return sourceTemplate; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineTrashChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineTrashChange.java new file mode 100644 index 000000000..9754dcb6b --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/MachineTrashChange.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.mqtt.CdbMqttTopic; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; + +/** Pending context for a machine trash or restore event. */ +public final class MachineTrashChange { + + private final CdbMqttTopic topic; + private final ItemDomainMachineDesign requestedHierarchyRoot; + private final int affectedMachineCount; + + public MachineTrashChange(CdbMqttTopic topic, + ItemDomainMachineDesign requestedRoot, int affectedCount) { + this.topic = topic; + this.requestedHierarchyRoot = requestedRoot; + this.affectedMachineCount = affectedCount; + } + + public CdbMqttTopic getTopic() { return topic; } + public ItemDomainMachineDesign getRequestedHierarchyRoot() { return requestedHierarchyRoot; } + public int getAffectedMachineCount() { return affectedMachineCount; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/PropertyValueChange.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/PropertyValueChange.java new file mode 100644 index 000000000..511dd46ac --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/metadata/PropertyValueChange.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.metadata; + +import gov.anl.aps.cdb.portal.model.db.entities.PropertyValue; + +/** Pending pre-update state for a property-value change. */ +public final class PropertyValueChange { + + public enum Type { ADD, UPDATE, DELETE } + + private final Type type; + private final PropertyValue oldValue; + private final PropertyValue newValue; + + private PropertyValueChange(Type type, PropertyValue oldValue, PropertyValue newValue) { + this.type = type; + this.oldValue = oldValue; + this.newValue = newValue; + } + + public static PropertyValueChange added(PropertyValue value) { + return new PropertyValueChange(Type.ADD, null, value); + } + + public static PropertyValueChange updated(PropertyValue oldValue, PropertyValue newValue) { + return new PropertyValueChange(Type.UPDATE, oldValue, newValue); + } + + public static PropertyValueChange deleted(PropertyValue value) { + return new PropertyValueChange(Type.DELETE, value, null); + } + + public Type getType() { return type; } + public PropertyValue getOldValue() { return oldValue; } + public PropertyValue getNewValue() { return newValue; } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtility.java new file mode 100644 index 000000000..243601ed6 --- /dev/null +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtility.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.utilities; + +/** Text bounding and line-diff helpers shared by MQTT events that carry free text. */ +public final class CdbMqttTextUtility { + + public static final int MAX_TEXT_LENGTH = 16 * 1024; + + private CdbMqttTextUtility() { + } + + /** Line diff of the changed middle section: removed lines as '-', added lines as '+'. */ + public static String createLineDiff(String oldText, String newText) { + String[] oldLines = lines(oldText); + String[] newLines = lines(newText); + int prefix = 0; + while (prefix < oldLines.length && prefix < newLines.length + && oldLines[prefix].equals(newLines[prefix])) { + prefix++; + } + int suffix = 0; + while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix + && oldLines[oldLines.length - suffix - 1] + .equals(newLines[newLines.length - suffix - 1])) { + suffix++; + } + + StringBuilder result = new StringBuilder(); + for (int i = prefix; i < oldLines.length - suffix; i++) { + result.append('-').append(oldLines[i]).append('\n'); + } + for (int i = prefix; i < newLines.length - suffix; i++) { + result.append('+').append(newLines[i]).append('\n'); + } + return result.toString(); + } + + public static int length(String value) { + return value == null ? 0 : value.length(); + } + + public static boolean exceedsLimit(String value) { + return length(value) > MAX_TEXT_LENGTH; + } + + public static String truncate(String value) { + return exceedsLimit(value) ? value.substring(0, MAX_TEXT_LENGTH) : value; + } + + private static String[] lines(String value) { + return value == null || value.isEmpty() ? new String[0] : value.split("\\R", -1); + } +} diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/CdbDomainEntityController.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/CdbDomainEntityController.java index a024a7904..47b7c8cc2 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/CdbDomainEntityController.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/CdbDomainEntityController.java @@ -557,7 +557,10 @@ public void deleteLog(Log cdbDomainEntityLog) { EntityType cdbDomainEntity = getCurrent(); List cdbDomainEntityLogList = cdbDomainEntity.getLogList(); cdbDomainEntityLogList.remove(cdbDomainEntityLog); - updateOnRemoval(); + // Unlink via the owner update first (its log diff still sees the row), then delete the log. + if (updateOnRemoval() != null) { + LogController.getInstance().destroy(cdbDomainEntityLog); + } } public List getFilteredPropertyValueList() { diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignBaseController.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignBaseController.java index 30c55152b..9a8fa0bac 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignBaseController.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignBaseController.java @@ -7,6 +7,8 @@ import gov.anl.aps.cdb.common.constants.CdbRole; import gov.anl.aps.cdb.portal.controllers.extensions.CableWizard; import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.mqtt.CdbMqttTopic; +import gov.anl.aps.cdb.mqtt.metadata.MachineTrashChange; import gov.anl.aps.cdb.portal.constants.EntityTypeName; import gov.anl.aps.cdb.portal.constants.ItemDomainName; import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; @@ -3332,6 +3334,8 @@ public void moveToTrash() { ItemDomainMachineDesign current = getCurrent(); ItemDomainMachineDesign parentMachineDesign = current.getParentMachineDesign(); + prepareTrashChanges(moveToTrashItemsToUpdate, rootItemToDelete, + CdbMqttTopic.MACHINE_DESIGN_TRASH, false); // mark items moving to trash as deleted and remove relationship between root item and its parent ValidInfo moveValidInfo = handleMoveToTrash(moveToTrashItemsToUpdate, moveToTrashElementsToDelete); if (!moveValidInfo.isValid()) { @@ -3370,6 +3374,24 @@ protected void performMoveToTrashOperationsForItem(ItemDomainMachineDesign item) item.setIsDeleted(); } + protected void prepareTrashChanges(List items, + ItemDomainMachineDesign requestedRoot, CdbMqttTopic topic, + boolean expectedDeletedState) { + List changedItems = new ArrayList<>(); + for (ItemDomainMachineDesign item : items) { + ItemDomainMachineDesign originalItem = findById(item.getId()); + if (originalItem != null + && originalItem.getIsItemDeleted() == expectedDeletedState) { + changedItems.add(item); + } + } + int affectedCount = changedItems.size(); + for (ItemDomainMachineDesign item : changedItems) { + item.setPendingTrashChange( + new MachineTrashChange(topic, requestedRoot, affectedCount)); + } + } + // // public EntityInfo getEntityInfoBranchUpdate() { diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignDeletedItemsController.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignDeletedItemsController.java index 91e694bdc..cc33699b3 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignDeletedItemsController.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/ItemDomainMachineDesignDeletedItemsController.java @@ -5,6 +5,7 @@ package gov.anl.aps.cdb.portal.controllers; import gov.anl.aps.cdb.common.constants.CdbRole; +import gov.anl.aps.cdb.mqtt.CdbMqttTopic; import gov.anl.aps.cdb.common.exceptions.CdbException; import gov.anl.aps.cdb.portal.constants.EntityTypeName; import gov.anl.aps.cdb.portal.controllers.settings.ItemDomainMachineDesignDeletedItemSettings; @@ -196,6 +197,8 @@ public void restoreDeletedItem() { } } + prepareTrashChanges(itemsToRestore, itemToRestore, + CdbMqttTopic.MACHINE_DESIGN_RESTORE, true); // remove 'deleted' entity type for all items for (ItemDomainMachineDesign item : itemsToRestore) { item.unsetIsDeleted(); diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/LocatableItemController.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/LocatableItemController.java index cab450cce..23eafc652 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/LocatableItemController.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/LocatableItemController.java @@ -8,6 +8,7 @@ import gov.anl.aps.cdb.common.exceptions.InvalidRequest; import gov.anl.aps.cdb.portal.constants.ItemDomainName; import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; +import gov.anl.aps.cdb.mqtt.metadata.ItemLocationChange; import gov.anl.aps.cdb.portal.model.db.beans.ItemElementRelationshipFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemFacade; import gov.anl.aps.cdb.portal.model.db.beans.RelationshipTypeFacade; @@ -1005,6 +1006,10 @@ public void updateItemLocation(LocatableItem item, UserInfo updateUser) throws I } if (newItemWithNewLocation || locationDifferentOnCurrentItem) { + Item oldLocation = existingItem == null ? null : existingItem.getLocationItem(); + String oldLocationDetails = existingItem == null + ? null : normalizeLocationDetails(existingItem.getLocationDetails()); + String newLocationDetails = normalizeLocationDetails(item.getLocationDetails()); // Verify that the location can be changed. if ((existingItem != null) && (locationEditable(existingItem)) == false) { Item location = existingItem.getActiveLocation(); @@ -1064,6 +1069,8 @@ public void updateItemLocation(LocatableItem item, UserInfo updateUser) throws I } ierhList.add(ierh); + item.setPendingLocationChange(new ItemLocationChange( + oldLocation, oldLocationDetails, item.getLocationItem(), newLocationDetails)); } else { logger.error("updateItemLocation(): item location relationship unexpectedly null for item: " + item.toString()); @@ -1071,6 +1078,10 @@ public void updateItemLocation(LocatableItem item, UserInfo updateUser) throws I } } + private static String normalizeLocationDetails(String details) { + return details == null || details.isEmpty() ? null : details; + } + private RelationshipType getLocationRelationshipType() { return relationshipTypeFacade.findByName(ItemElementRelationshipTypeNames.itemLocation.getValue()); } diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtility.java index 21c171c37..ed91c0a51 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtility.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtility.java @@ -5,6 +5,10 @@ package gov.anl.aps.cdb.portal.controllers.utilities; import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbEntityEvent; +import gov.anl.aps.cdb.mqtt.CdbMqttPublisher; +import gov.anl.aps.cdb.mqtt.CdbMqttTopic; import gov.anl.aps.cdb.common.utilities.StringUtility; import gov.anl.aps.cdb.portal.constants.SystemLogLevel; import gov.anl.aps.cdb.portal.model.db.beans.CdbEntityFacade; @@ -14,6 +18,8 @@ import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; import gov.anl.aps.cdb.portal.utilities.SearchResult; import gov.anl.aps.cdb.portal.utilities.SessionUtility; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.LinkedList; import java.util.List; @@ -51,10 +57,14 @@ public abstract class CdbEntityControllerUtility persisted = Collections.singletonList(entity); + prepareSpecializedCreateEvents(persisted, createdByUserInfo); getEntityDbFacade().create(entity); addCreatedSystemLog(entity, createdByUserInfo); entity.setPersitanceErrorMessage(null); + publishMqttEvent(CdbMqttTopic.ADD, entity, createdByUserInfo); + publishMqttEvents(createSpecializedCreateEvents(persisted, createdByUserInfo)); clearCaches(); return entity; @@ -77,9 +87,14 @@ public void createList(List entities, UserInfo createdByUserInfo) th for (EntityType entity : entities) { prepareEntityInsert(entity, createdByUserInfo); } + prepareSpecializedCreateEvents(entities, createdByUserInfo); getEntityDbFacade().create(entities); - addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Created " + entities.size() + " entities.", createdByUserInfo); + addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Created " + entities.size() + " entities.", createdByUserInfo); + for (EntityType entity : entities) { + publishMqttEvent(CdbMqttTopic.ADD, entity, createdByUserInfo); + } + publishMqttEvents(createSpecializedCreateEvents(entities, createdByUserInfo)); setPersistenceErrorMessageForList(entities, null); clearCaches(); } catch (CdbException ex) { @@ -100,9 +115,15 @@ public EntityType update(EntityType entity, UserInfo updatedByUserInfo) throws C try { logger.debug("Updating " + getDisplayEntityTypeName() + " " + getEntityInstanceName(entity)); prepareEntityUpdate(entity, updatedByUserInfo); + List specializedUpdateEvents = prepareSpecializedUpdateEvents(entity, updatedByUserInfo); + List persisted = Collections.singletonList(entity); + prepareSpecializedCreateEvents(persisted, updatedByUserInfo); EntityType updatedEntity = getEntityDbFacade().edit(entity); addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Updated: " + entity.getSystemLogString(), updatedByUserInfo); entity.setPersitanceErrorMessage(null); + publishMqttEvent(CdbMqttTopic.UPDATE, updatedEntity, updatedByUserInfo); + publishMqttEvents(specializedUpdateEvents); + publishMqttEvents(createSpecializedCreateEvents(persisted, updatedByUserInfo)); clearCaches(); return updatedEntity; @@ -126,7 +147,13 @@ public EntityType updateOnRemoval(EntityType entity, UserInfo updatedByUserInfo) try { logger.debug("Updating " + getDisplayEntityTypeName() + " " + getEntityInstanceName(entity)); prepareEntityUpdateOnRemoval(entity); + List specializedUpdateEvents = prepareSpecializedUpdateEvents(entity, updatedByUserInfo); + List persisted = Collections.singletonList(entity); + prepareSpecializedCreateEvents(persisted, updatedByUserInfo); EntityType updatedEntity = getEntityDbFacade().edit(entity); + publishMqttEvent(CdbMqttTopic.UPDATE, updatedEntity, updatedByUserInfo); + publishMqttEvents(specializedUpdateEvents); + publishMqttEvents(createSpecializedCreateEvents(persisted, updatedByUserInfo)); clearCaches(); return updatedEntity; @@ -148,15 +175,21 @@ public EntityType updateOnRemoval(EntityType entity, UserInfo updatedByUserInfo) public void updateList(List entities, UserInfo updatedByUserInfo) throws CdbException, RuntimeException { try { + List specializedUpdateEvents = new ArrayList<>(); for (EntityType entity : entities) { logger.debug("Updating " + getDisplayEntityTypeName() + " " + getEntityInstanceName(entity)); prepareEntityUpdate(entity, updatedByUserInfo); + specializedUpdateEvents.addAll(prepareSpecializedUpdateEvents(entity, updatedByUserInfo)); } + prepareSpecializedCreateEvents(entities, updatedByUserInfo); getEntityDbFacade().edit(entities); for (EntityType entity : entities) { entity.setPersitanceErrorMessage(null); addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Updated: " + entity.getSystemLogString(), updatedByUserInfo); + publishMqttEvent(CdbMqttTopic.UPDATE, entity, updatedByUserInfo); } + publishMqttEvents(specializedUpdateEvents); + publishMqttEvents(createSpecializedCreateEvents(entities, updatedByUserInfo)); clearCaches(); } catch (CdbException ex) { logger.error("Could not update " + getDisplayEntityTypeName() + " entities: " + ex.getMessage()); @@ -177,7 +210,8 @@ public void destroy(EntityType entity, UserInfo destroyedByUserInfo) throws CdbE prepareEntityDestroy(entity, destroyedByUserInfo); getEntityDbFacade().remove(entity); - addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Deleted: " + entity.getSystemLogString(), destroyedByUserInfo); + addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Deleted: " + entity.getSystemLogString(), destroyedByUserInfo); + publishMqttEvent(CdbMqttTopic.DELETE, entity, destroyedByUserInfo); clearCaches(); } catch (CdbException ex) { entity.setPersitanceErrorMessage(ex.getMessage()); @@ -214,6 +248,14 @@ public void destroyList( getEntityDbFacade().remove(entities, updateEntity); addCdbEntitySystemLog(SystemLogLevel.entityInfo, "Deleted: " + entities.size() + " entities.", destroyedByUserInfo); + for (EntityType entity : entities) { + if (entity != null) { + publishMqttEvent(CdbMqttTopic.DELETE, entity, destroyedByUserInfo); + } + } + if (updateEntity != null) { + publishMqttEvent(CdbMqttTopic.UPDATE, updateEntity, destroyedByUserInfo); + } setPersistenceErrorMessageForList(entities, null); clearCaches(); } catch (CdbException ex) { @@ -229,6 +271,44 @@ public void destroyList( } } + protected List prepareSpecializedUpdateEvents( + EntityType entity, UserInfo userInfo) throws CdbException { + return Collections.emptyList(); + } + + /** + * Captures entities about to be newly persisted (directly or nested); runs before every + * create and update facade call since updates can cascade new children. Events are built + * by createSpecializedCreateEvents once DB-assigned IDs exist. + */ + protected void prepareSpecializedCreateEvents( + List entities, UserInfo userInfo) throws CdbException { + } + + /** Builds create events from state captured by prepareSpecializedCreateEvents after a successful facade call. */ + protected List createSpecializedCreateEvents( + List entities, UserInfo userInfo) { + return Collections.emptyList(); + } + + protected void publishMqttEvents(List events) { + for (MqttEvent event : events) { + publishMqttEvent(event); + } + } + + protected void publishMqttEvent(CdbMqttTopic topic, EntityType entity, UserInfo userInfo) { + try { + publishMqttEvent(new CdbEntityEvent(topic, entity, userInfo)); + } catch (RuntimeException ex) { + logger.error("Failed to create MQTT event for " + getDisplayEntityTypeName(), ex); + } + } + + protected void publishMqttEvent(MqttEvent event) { + CdbMqttPublisher.publish(event); + } + /** * On database operation clear cache of related cached entity when needed. */ diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemControllerUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemControllerUtility.java index ac9b4ed6b..1445301a6 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemControllerUtility.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemControllerUtility.java @@ -4,12 +4,29 @@ */ package gov.anl.aps.cdb.portal.controllers.utilities; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; import gov.anl.aps.cdb.common.exceptions.CdbException; import gov.anl.aps.cdb.common.exceptions.InvalidRequest; import gov.anl.aps.cdb.common.exceptions.ObjectAlreadyExists; +import gov.anl.aps.cdb.mqtt.CdbInventoryAssemblyElementEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogAttachmentEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLocationEvent; +import gov.anl.aps.cdb.mqtt.CdbItemPropertyEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineAssignedItemEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineRepresentedElementEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineTrashEvent; import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; import gov.anl.aps.cdb.portal.constants.ListName; import gov.anl.aps.cdb.portal.controllers.PropertyTypeController; +import gov.anl.aps.cdb.mqtt.metadata.InventoryAssemblyElementChange; +import gov.anl.aps.cdb.mqtt.metadata.ItemLocationChange; +import gov.anl.aps.cdb.mqtt.metadata.ItemLogChange; +import gov.anl.aps.cdb.mqtt.metadata.LogAttachmentChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineAssignedItemChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineRepresentedElementChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineTrashChange; +import gov.anl.aps.cdb.mqtt.metadata.PropertyValueChange; import gov.anl.aps.cdb.portal.model.db.beans.AllowedPropertyMetadataValueFacade; import gov.anl.aps.cdb.portal.model.db.beans.DomainFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemFacadeBase; @@ -18,14 +35,18 @@ import gov.anl.aps.cdb.portal.model.db.beans.RelationshipTypeFacade; import gov.anl.aps.cdb.portal.model.db.entities.AllowedPropertyMetadataValue; import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; import gov.anl.aps.cdb.portal.model.db.entities.Item; import gov.anl.aps.cdb.portal.model.db.entities.ItemConnector; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventoryBase; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; import gov.anl.aps.cdb.portal.model.db.entities.ItemElementRelationship; import gov.anl.aps.cdb.portal.model.db.entities.ItemSource; import gov.anl.aps.cdb.portal.model.db.entities.ListTbl; import gov.anl.aps.cdb.portal.model.db.entities.LocatableItem; +import gov.anl.aps.cdb.portal.model.db.entities.Log; import gov.anl.aps.cdb.portal.model.db.beans.PropertyTypeHandlerFacade; import gov.anl.aps.cdb.portal.model.db.entities.PropertyType; import gov.anl.aps.cdb.portal.model.db.entities.PropertyTypeHandler; @@ -36,6 +57,7 @@ import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; import gov.anl.aps.cdb.portal.model.db.utilities.EntityInfoUtility; import gov.anl.aps.cdb.portal.model.db.utilities.ItemElementUtility; +import gov.anl.aps.cdb.portal.model.db.utilities.LogUtility; import gov.anl.aps.cdb.portal.model.db.utilities.PropertyValueUtility; import gov.anl.aps.cdb.portal.model.jsf.handlers.PropertyTypeHandlerFactory; import gov.anl.aps.cdb.portal.model.jsf.handlers.PropertyTypeHandlerInterface; @@ -46,8 +68,12 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Function; import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; @@ -125,9 +151,13 @@ public void prepareEntityUpdate(ItemDomainEntity item, UserInfo updatedByUser) t // Prepare history Item originalItem = findById(item.getId()); + prepareMachineAssignedItemChange(originalItem, item); + prepareMachineRepresentedElementChange(originalItem, item); // Full item element list contains self element as well as all the elements List originalElementList = originalItem.getFullItemElementList(); List newElementList = item.getFullItemElementList(); + prepareInventoryAssemblyElementChanges(originalItem, item, + originalElementList, newElementList); logger.debug("Verifying elements for item " + item); ItemElementUtility.prepareItemElementHistory(originalElementList, newElementList, entityInfo); //Verify reverse hierarchy updates @@ -139,8 +169,14 @@ public void prepareEntityUpdate(ItemDomainEntity item, UserInfo updatedByUser) t List originalPropertyValueList = originalItem.getPropertyValueList(); List newPropertyValueList = item.getPropertyValueList(); logger.debug("Verifying properties for item " + item); - PropertyValueUtility.preparePropertyValueHistory(originalPropertyValueList, newPropertyValueList, entityInfo); + List propertyChanges = PropertyValueUtility.preparePropertyValueHistory( + originalPropertyValueList, newPropertyValueList, entityInfo); + item.setPendingPropertyValueChanges(propertyChanges); item.clearPropertyValueCache(); + item.setPendingLogChanges(LogUtility.prepareLogChanges( + originalItem.getLogList(), item.getLogList())); + item.setPendingLogAttachmentChanges(LogUtility.findAddedAttachments( + originalItem.getLogList(), item.getLogList())); List derivedFromItemList = item.getDerivedFromItemList(); if (derivedFromItemList != null) { @@ -156,6 +192,293 @@ public void prepareEntityUpdate(ItemDomainEntity item, UserInfo updatedByUser) t } + protected void prepareInventoryAssemblyElementChanges(Item originalItem, Item item, + List originalElementList, List newElementList) { + if (!(item instanceof ItemDomainInventoryBase)) { + return; + } + ItemDomainInventoryBase inventoryAssembly = (ItemDomainInventoryBase) item; + for (ItemElement newElement : newElementList) { + if (newElement.equals(item.getSelfElement())) { + continue; + } + int index = originalElementList.indexOf(newElement); + Item oldAssignedItem = index < 0 + ? null : originalElementList.get(index).getContainedItem(); + Item newAssignedItem = newElement.getContainedItem(); + if (!Objects.equals(oldAssignedItem, newAssignedItem)) { + inventoryAssembly.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange( + newElement, oldAssignedItem, newAssignedItem)); + } + } + } + + protected void prepareMachineAssignedItemChange(Item originalItem, Item item) { + if (item instanceof ItemDomainMachineDesign) { + ItemDomainMachineDesign machine = (ItemDomainMachineDesign) item; + ItemDomainMachineDesign originalMachine = (ItemDomainMachineDesign) originalItem; + Item oldAssignedItem = originalMachine.getAssignedItem(); + Item newAssignedItem = machine.getAssignedItem(); + if (!Objects.equals(oldAssignedItem, newAssignedItem) + || originalMachine.isIsHoused() != machine.isIsHoused()) { + machine.setPendingAssignedItemChange( + oldAssignedItem, originalMachine.isIsHoused()); + } + } + } + + protected void prepareMachineRepresentedElementChange(Item originalItem, Item item) { + if (item instanceof ItemDomainMachineDesign) { + ItemDomainMachineDesign machine = (ItemDomainMachineDesign) item; + ItemDomainMachineDesign originalMachine = (ItemDomainMachineDesign) originalItem; + if (!Objects.equals(originalMachine.getRepresentsCatalogElement(), + machine.getRepresentsCatalogElement())) { + machine.setPendingRepresentedElementChange( + originalMachine.getRepresentsCatalogElement()); + } + } + } + + @Override + protected List prepareSpecializedUpdateEvents( + ItemDomainEntity item, UserInfo userInfo) throws CdbException { + List events = new ArrayList<>(); + if (item instanceof ItemDomainInventoryBase) { + addInventoryAssemblyElementEvents(events, + (ItemDomainInventoryBase) item, userInfo, new HashSet<>()); + } + if (item instanceof LocatableItem) { + LocatableItem locatableItem = (LocatableItem) item; + ItemLocationChange locationChange = locatableItem.getPendingLocationChange(); + if (locationChange != null) { + events.add(new CdbItemLocationEvent(locatableItem, + locationChange.getOldLocation(), locationChange.getNewLocation(), + locationChange.getOldLocationDetails(), + locationChange.getNewLocationDetails(), userInfo)); + } + } + if (item instanceof ItemDomainMachineDesign) { + ItemDomainMachineDesign machine = (ItemDomainMachineDesign) item; + MachineAssignedItemChange change = machine.getPendingAssignedItemChange(); + if (change != null) { + events.add(new CdbMachineAssignedItemEvent(machine, + change.getOldAssignedItem(), machine.getAssignedItem(), + change.isOldInstalled(), machine.isIsHoused(), userInfo)); + } + addRepresentedElementEvents(events, machine, userInfo); + MachineTrashChange trashChange = machine.getPendingTrashChange(); + if (trashChange != null) { + events.add(new CdbMachineTrashEvent(trashChange.getTopic(), machine, + trashChange.getRequestedHierarchyRoot(), + trashChange.getAffectedMachineCount(), userInfo)); + } + } + for (PropertyValueChange change : item.getPendingPropertyValueChanges()) { + switch (change.getType()) { + case ADD: + events.add(CdbItemPropertyEvent.added(item, change.getNewValue(), userInfo)); + break; + case UPDATE: + events.add(CdbItemPropertyEvent.updated(item, + change.getOldValue(), change.getNewValue(), userInfo)); + break; + case DELETE: + events.add(CdbItemPropertyEvent.deleted(item, change.getOldValue(), userInfo)); + break; + default: + throw new IllegalStateException("Unsupported property change type"); + } + } + for (ItemLogChange change : item.getPendingLogChanges()) { + if (change.getType() == ItemLogChange.Type.UPDATE) { + events.add(CdbItemLogEvent.updated(item, + change.getOldLog(), change.getNewLog(), userInfo)); + } else { + events.add(CdbItemLogEvent.deleted(item, change.getOldLog(), userInfo)); + } + } + item.setPendingLogChanges(null); + Set publishedAttachments = Collections.newSetFromMap(new IdentityHashMap<>()); + for (LogAttachmentChange change : item.getPendingLogAttachmentChanges()) { + if (publishedAttachments.add(change.getAttachment())) { + events.add(new CdbItemLogAttachmentEvent(item, + change.getLog(), change.getAttachment(), userInfo)); + } + } + item.setPendingLogAttachmentChanges(null); + return events; + } + + @Override + protected void prepareSpecializedCreateEvents( + List entities, UserInfo userInfo) { + for (ItemDomainEntity item : entities) { + List addedLogs = new ArrayList<>(); + List logs = item.getLogList(); + if (logs != null) { + for (Log log : logs) { + if (log.getId() == null) { + addedLogs.add(log); + } + } + } + item.setPendingAddedLogs(addedLogs); + } + } + + @Override + protected List createSpecializedCreateEvents( + List entities, UserInfo userInfo) { + List events = new ArrayList<>(); + for (ItemDomainEntity item : entities) { + List addedLogs = item.getPendingAddedLogs(); + item.setPendingAddedLogs(null); + if (addedLogs.isEmpty()) { + continue; + } + try { + Map resolvedLogs = resolvePersistedLogs(item, addedLogs); + for (Log log : addedLogs) { + Log persistedLog = resolvedLogs.get(log); + if (persistedLog == null) { + continue; + } + events.add(CdbItemLogEvent.added(item, persistedLog, userInfo)); + // A new log's attachments need its DB-assigned ID, so they follow its add. + addLogAttachmentEvents(events, item, persistedLog, + log.getAttachmentList(), userInfo); + } + } catch (RuntimeException ex) { + logger.error("Failed to create item log add MQTT events", ex); + } + } + return events; + } + + private void addLogAttachmentEvents(List events, ItemDomainEntity item, + Log log, List attachments, UserInfo userInfo) { + if (attachments == null) { + return; + } + Set published = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Attachment attachment : attachments) { + if (published.add(attachment)) { + events.add(new CdbItemLogAttachmentEvent(item, log, attachment, userInfo)); + } + } + } + + /** + * Associates an attachment with one of the item's log entries and saves it through the + * item update, so attachment uploads share the specialized create/update event hooks. + */ + public ItemDomainEntity addLogAttachment(ItemDomainEntity item, Log log, + Attachment attachment, UserInfo user) throws CdbException { + List logs = item.getLogList(); + if (logs == null || !logs.contains(log)) { + throw new CdbException("Log " + log.getId() + " does not belong to item " + item.getId()); + } + LogUtility.addAttachment(log, attachment); + return update(item, user); + } + + /** + * Maps each submitted new log (by identity) to its persisted log. Create persists the + * submitted logs (IDs assigned in place); update merges them, so IDs exist only on + * managed copies and are read back like ItemRoute does for new properties. + */ + private Map resolvePersistedLogs(ItemDomainEntity item, List addedLogs) { + Map resolved = new IdentityHashMap<>(); + Set knownIds = new HashSet<>(); + if (item.getLogList() != null) { + for (Log log : item.getLogList()) { + if (log.getId() != null) { + knownIds.add(log.getId()); + } + } + } + List candidates = null; + for (Log log : addedLogs) { + if (log.getId() != null) { + resolved.put(log, log); + continue; + } + if (candidates == null) { + candidates = new ArrayList<>(); + ItemDomainEntity persistedItem = item.getId() == null ? null : findById(item.getId()); + List persistedLogs = persistedItem == null ? null : persistedItem.getLogList(); + if (persistedLogs != null) { + for (Log candidate : persistedLogs) { + if (candidate.getId() != null && !knownIds.contains(candidate.getId())) { + candidates.add(candidate); + } + } + } + } + Log match = null; + for (Log candidate : candidates) { + if (Objects.equals(candidate.getText(), log.getText()) + && Objects.equals(candidate.getEnteredByUser(), log.getEnteredByUser())) { + match = candidate; + break; + } + } + if (match == null) { + logger.warn("Could not resolve persisted log for MQTT add event on " + item); + } else { + candidates.remove(match); + resolved.put(log, match); + } + } + return resolved; + } + + private void addInventoryAssemblyElementEvents(List events, + ItemDomainInventoryBase inventoryAssembly, UserInfo userInfo, + Set visited) { + if (!visited.add(inventoryAssembly)) { + return; + } + for (Object value : inventoryAssembly.getPendingAssemblyElementChanges()) { + InventoryAssemblyElementChange change = (InventoryAssemblyElementChange) value; + events.add(new CdbInventoryAssemblyElementEvent(inventoryAssembly, + change.getAssemblyElement(), change.getOldAssignedInventoryItem(), + change.getNewAssignedInventoryItem(), userInfo)); + if (change.getNewAssignedInventoryItem() instanceof ItemDomainInventoryBase) { + addInventoryAssemblyElementEvents(events, + (ItemDomainInventoryBase) change.getNewAssignedInventoryItem(), + userInfo, visited); + } + } + } + + private void addRepresentedElementEvents(List events, + ItemDomainMachineDesign machine, UserInfo userInfo) { + MachineRepresentedElementChange change = machine.getPendingRepresentedElementChange(); + if (change != null) { + events.add(new CdbMachineRepresentedElementEvent(machine, + change.getOldRepresentedElement(), machine.getRepresentsCatalogElement(), + change.getSourceTemplate(), userInfo)); + } + for (ItemDomainMachineDesign itemToUpdate : machine.getItemsToUpdate()) { + addRepresentedElementEvents(events, itemToUpdate, userInfo); + } + } + + @Override + protected void prepareEntityUpdateOnRemoval(ItemDomainEntity item) throws CdbException { + Item originalItem = findById(item.getId()); + List propertyChanges = PropertyValueUtility.findRemovedPropertyValues( + originalItem.getPropertyValueList(), item.getPropertyValueList()); + item.setPendingPropertyValueChanges(propertyChanges); + // Full diff: the merge also persists any edits made alongside the removal. + item.setPendingLogChanges(LogUtility.prepareLogChanges( + originalItem.getLogList(), item.getLogList())); + item.setPendingLogAttachmentChanges(LogUtility.findAddedAttachments( + originalItem.getLogList(), item.getLogList())); + } + @Override protected void prepareEntityDestroy(ItemDomainEntity item, UserInfo userInfo) throws CdbException { super.prepareEntityDestroy(item, userInfo); diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignBaseControllerUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignBaseControllerUtility.java index ea9a828a6..978913bb1 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignBaseControllerUtility.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignBaseControllerUtility.java @@ -8,6 +8,10 @@ import gov.anl.aps.cdb.common.exceptions.InvalidArgument; import gov.anl.aps.cdb.common.exceptions.InvalidObjectState; import gov.anl.aps.cdb.common.utilities.ObjectUtility; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineAddEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineMoveEvent; +import gov.anl.aps.cdb.mqtt.metadata.MachineAddChange; import gov.anl.aps.cdb.portal.constants.EntityTypeName; import gov.anl.aps.cdb.portal.constants.ItemDomainName; import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; @@ -30,8 +34,13 @@ import gov.anl.aps.cdb.portal.utilities.SearchResult; import gov.anl.aps.cdb.portal.view.objects.KeyValueObject; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; import java.util.LinkedList; import java.util.List; +import java.util.Objects; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.primefaces.model.DefaultTreeNode; @@ -342,8 +351,13 @@ public ItemElement performMachineMove( public ItemElement performMachineMoveWithUpdate( ItemDomainMachineDesign newParent, ItemDomainMachineDesign child, UserInfo sessionUser) throws CdbException { + ItemDomainMachineDesign oldParent = child.getParentMachineDesign(); + if (Objects.equals(oldParent, newParent)) { + return child.getParentMachineElement(); + } ItemElement currentItemElement = performMachineMove(newParent, child, sessionUser); update(newParent, sessionUser); + publishMqttEvent(new CdbMachineMoveEvent(child, oldParent, newParent, sessionUser)); return currentItemElement; } @@ -849,7 +863,14 @@ private void updateRepresentingAssemblyElementForMachine(ItemDomainMachineDesign } } + ItemElement oldRepresentedElement = node.getRepresentsCatalogElement(); node.setRepresentsCatalogElement(representedElement); + if (updateFromTemplate && !Objects.equals(oldRepresentedElement, representedElement)) { + Item template = node.getCreatedFromTemplate(); + node.setPendingRepresentedElementChange(oldRepresentedElement, + template instanceof ItemDomainMachineDesign + ? (ItemDomainMachineDesign) template : null); + } if (node.getIsItemTemplate()) { List itemsCreatedFromThisTemplateItem @@ -943,4 +964,165 @@ public void unassignMachineTemplate(List machineElementList, UserIn ieru.destroyList(relationshipsToDestroy, null, updateUser); } + @Override + protected void prepareSpecializedCreateEvents( + List entities, UserInfo userInfo) { + super.prepareSpecializedCreateEvents(entities, userInfo); + // Identity set: unsaved items compare equal by identifiers; one node may be reachable via several roots. + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + List> changesPerEntity = new ArrayList<>(); + List allChanges = new ArrayList<>(); + for (ItemDomainMachineDesign entity : entities) { + List changes = new ArrayList<>(); + collectNewMachines(entity, entity.getParentMachineDesign(), true, changes, visited); + changesPerEntity.add(changes); + allChanges.addAll(changes); + } + Map templateChanges = addTemplateContext(allChanges); + for (int i = 0; i < entities.size(); i++) { + List changes = new ArrayList<>(); + for (MachineAddChange change : changesPerEntity.get(i)) { + changes.add(templateChanges.getOrDefault(change.getMachine(), change)); + } + entities.get(i).setPendingMachineAddChanges(changes); + } + } + + /** + * Resolves template context across one save: each template-cloned node gets its source + * template, the machine the template was applied to, and how many nodes that application added. + */ + private Map addTemplateContext( + List changes) { + Map changeByMachine = new IdentityHashMap<>(); + Map templateByMachine = new IdentityHashMap<>(); + for (MachineAddChange change : changes) { + changeByMachine.put(change.getMachine(), change); + ItemDomainMachineDesign template = getSourceTemplate(change.getMachine()); + if (template != null) { + templateByMachine.put(change.getMachine(), template); + } + } + + Map appliedToByMachine = new IdentityHashMap<>(); + Map countByAppliedTo = new IdentityHashMap<>(); + for (ItemDomainMachineDesign machine : templateByMachine.keySet()) { + ItemDomainMachineDesign appliedTo = machine; + ItemDomainMachineDesign parent = changeByMachine.get(machine).getParent(); + while (parent != null) { + MachineAddChange parentChange = changeByMachine.get(parent); + if (parentChange != null && templateByMachine.containsKey(parent)) { + appliedTo = parent; + parent = parentChange.getParent(); + continue; + } + // Existing machine whose template was just assigned (relationship not yet saved). + if (parentChange == null && hasUnsavedTemplateRelationship(parent)) { + appliedTo = parent; + } + break; + } + appliedToByMachine.put(machine, appliedTo); + countByAppliedTo.merge(appliedTo, 1, Integer::sum); + } + + Map result = new IdentityHashMap<>(); + for (Map.Entry entry : appliedToByMachine.entrySet()) { + MachineAddChange change = changeByMachine.get(entry.getKey()); + result.put(entry.getKey(), new MachineAddChange(change.getMachine(), change.getParent(), + templateByMachine.get(entry.getKey()), entry.getValue(), + countByAppliedTo.get(entry.getValue()))); + } + return result; + } + + private static ItemDomainMachineDesign getSourceTemplate(ItemDomainMachineDesign machine) { + ItemElementRelationship relationship = getTemplateRelationship(machine); + if (relationship == null || relationship.getSecondItemElement() == null) { + return null; + } + Item template = relationship.getSecondItemElement().getParentItem(); + return template instanceof ItemDomainMachineDesign ? (ItemDomainMachineDesign) template : null; + } + + private static boolean hasUnsavedTemplateRelationship(ItemDomainMachineDesign machine) { + ItemElementRelationship relationship = getTemplateRelationship(machine); + return relationship != null && relationship.getId() == null; + } + + // Null-safe variant of Item.getCreatedFromTemplateRelationship for unsaved clones. + private static ItemElementRelationship getTemplateRelationship(ItemDomainMachineDesign machine) { + List relationships = machine.getItemElementRelationshipList(); + if (relationships == null) { + return null; + } + String templateName = ItemElementRelationshipTypeNames.template.getValue(); + for (ItemElementRelationship relationship : relationships) { + if (relationship.getRelationshipType() != null + && templateName.equals(relationship.getRelationshipType().getName())) { + return relationship; + } + } + return null; + } + + // Mirrors ItemFacadeBase.populateItemsToAdd: only recurse below roots and unsaved nodes. + private void collectNewMachines(ItemDomainMachineDesign machine, + ItemDomainMachineDesign parent, boolean persistenceRoot, + List changes, Set visited) { + if (machine == null || !visited.add(machine)) { + return; + } + boolean persisted = isMachinePersisted(machine); + if (!persisted) { + changes.add(new MachineAddChange(machine, parent)); + } else if (!persistenceRoot) { + return; + } + ItemElement selfElement = machine.getSelfElement(); + List elements = machine.getFullItemElementList(); + if (elements != null) { + for (ItemElement element : elements) { + Item child = element == selfElement ? null : element.getContainedItem(); + if (child instanceof ItemDomainMachineDesign) { + collectNewMachines((ItemDomainMachineDesign) child, machine, false, + changes, visited); + } + } + } + // ItemDomainMachineDesignFacade.edit cascades into itemsToUpdate as further roots. + for (ItemDomainMachineDesign itemToUpdate : machine.getItemsToUpdate()) { + collectNewMachines(itemToUpdate, itemToUpdate.getParentMachineDesign(), true, + changes, visited); + } + } + + /** Mirrors ItemFacadeBase.populateItemsToAdd: an ID alone does not mean the row exists. */ + protected boolean isMachinePersisted(ItemDomainMachineDesign machine) { + return machine.getId() != null && getEntityDbFacade().findItem(machine.getId()) != null; + } + + @Override + protected List createSpecializedCreateEvents( + List entities, UserInfo userInfo) { + List events = new ArrayList<>(); + for (ItemDomainMachineDesign entity : entities) { + for (MachineAddChange change : entity.getPendingMachineAddChanges()) { + // Skip nodes that did not receive an ID (not actually persisted). + if (change.getMachine().getId() == null) { + continue; + } + try { + events.add(new CdbMachineAddEvent(change, userInfo)); + } catch (RuntimeException ex) { + logger.error("Failed to create machine add MQTT event", ex); + } + } + entity.setPendingMachineAddChanges(null); + } + // Machine adds precede log adds so a new machine is announced before its logs. + events.addAll(super.createSpecializedCreateEvents(entities, userInfo)); + return events; + } + } diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/Item.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/Item.java index c10f3c817..48dccd14f 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/Item.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/Item.java @@ -20,6 +20,9 @@ import gov.anl.aps.cdb.portal.model.db.beans.PropertyTypeFacade; import gov.anl.aps.cdb.portal.model.db.entities.comparator.ItemElementRelevantSortOrderComparator; import gov.anl.aps.cdb.portal.model.db.utilities.ItemElementUtility; +import gov.anl.aps.cdb.mqtt.metadata.ItemLogChange; +import gov.anl.aps.cdb.mqtt.metadata.LogAttachmentChange; +import gov.anl.aps.cdb.mqtt.metadata.PropertyValueChange; import gov.anl.aps.cdb.portal.utilities.SearchResult; import gov.anl.aps.cdb.portal.view.objects.ItemMetadataFieldInfo; import gov.anl.aps.cdb.portal.view.objects.ItemMetadataPropertyInfo; @@ -504,6 +507,12 @@ ) public class Item extends CdbDomainEntity implements Serializable { + private transient List pendingPropertyValueChanges = new ArrayList<>(); + private transient List pendingLogChanges = new ArrayList<>(); + // Self-element logs without IDs at save time; resolved to persisted logs afterward. + private transient List pendingAddedLogs = new ArrayList<>(); + private transient List pendingLogAttachmentChanges = new ArrayList<>(); + private static final Logger LOGGER = LogManager.getLogger(Item.class.getName()); public static final String ATTRIBUTE_DOMAIN_NAME = "domainName"; @@ -1595,6 +1604,48 @@ public void setPropertyValueList(List propertyValueList) { this.getSelfElement().setPropertyValueList(propertyValueList); } + @JsonIgnore + public void setPendingPropertyValueChanges(List changes) { + pendingPropertyValueChanges = changes == null ? new ArrayList<>() : new ArrayList<>(changes); + } + + @JsonIgnore + public List getPendingPropertyValueChanges() { + return pendingPropertyValueChanges == null + ? new ArrayList<>() : new ArrayList<>(pendingPropertyValueChanges); + } + + @JsonIgnore + public void setPendingLogChanges(List changes) { + pendingLogChanges = changes == null ? new ArrayList<>() : new ArrayList<>(changes); + } + + @JsonIgnore + public List getPendingLogChanges() { + return pendingLogChanges == null ? new ArrayList<>() : new ArrayList<>(pendingLogChanges); + } + + @JsonIgnore + public void setPendingAddedLogs(List logs) { + pendingAddedLogs = logs == null ? new ArrayList<>() : new ArrayList<>(logs); + } + + @JsonIgnore + public List getPendingAddedLogs() { + return pendingAddedLogs == null ? new ArrayList<>() : new ArrayList<>(pendingAddedLogs); + } + + @JsonIgnore + public void setPendingLogAttachmentChanges(List changes) { + pendingLogAttachmentChanges = changes == null ? new ArrayList<>() : new ArrayList<>(changes); + } + + @JsonIgnore + public List getPendingLogAttachmentChanges() { + return pendingLogAttachmentChanges == null + ? new ArrayList<>() : new ArrayList<>(pendingLogAttachmentChanges); + } + @Override public List getPropertyValueDisplayList() { if (propertyValueDisplayList == null) { diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainInventoryBase.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainInventoryBase.java index 317ce6a4b..cbd007308 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainInventoryBase.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainInventoryBase.java @@ -6,10 +6,12 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.mqtt.metadata.InventoryAssemblyElementChange; import gov.anl.aps.cdb.portal.import_export.import_.objects.ValidInfo; import gov.anl.aps.cdb.portal.model.db.utilities.ItemElementUtility; import gov.anl.aps.cdb.portal.model.jsf.beans.SparePartsBean; import gov.anl.aps.cdb.portal.view.objects.InventoryBillOfMaterialItem; +import java.util.ArrayList; import java.util.List; import org.primefaces.model.TreeNode; @@ -22,6 +24,7 @@ public abstract class ItemDomainInventoryBase inventoryDomainBillOfMaterialList = null; + private transient List pendingAssemblyElementChanges = new ArrayList<>(); private transient InventoryBillOfMaterialItem containedInBOM; @@ -56,6 +59,16 @@ public List getInventoryDomainBillOfMaterialList() public void setInventoryDomainBillOfMaterialList(List inventoryDomainBillOfMaterialList) { this.inventoryDomainBillOfMaterialList = inventoryDomainBillOfMaterialList; } + + @JsonIgnore + public void addPendingAssemblyElementChange(InventoryAssemblyElementChange change) { + pendingAssemblyElementChanges.add(change); + } + + @JsonIgnore + public List getPendingAssemblyElementChanges() { + return new ArrayList<>(pendingAssemblyElementChanges); + } @JsonIgnore public abstract String generateUnitName(int itemNumber); diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainMachineDesign.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainMachineDesign.java index 01f1d0492..6abfcfd46 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainMachineDesign.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/ItemDomainMachineDesign.java @@ -23,6 +23,10 @@ import gov.anl.aps.cdb.portal.controllers.utilities.ItemDomainMachineDesignIOCControllerUtility; import gov.anl.aps.cdb.portal.controllers.utilities.ItemDomainMachineDesignInventoryControllerUtility; import gov.anl.aps.cdb.portal.controllers.utilities.ItemDomainMachineDesignPowerControllerUtility; +import gov.anl.aps.cdb.mqtt.metadata.MachineAddChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineAssignedItemChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineRepresentedElementChange; +import gov.anl.aps.cdb.mqtt.metadata.MachineTrashChange; import gov.anl.aps.cdb.portal.utilities.SearchResult; import gov.anl.aps.cdb.portal.view.objects.KeyValueObject; import gov.anl.aps.cdb.portal.view.objects.MachineDesignConnectorListObject; @@ -63,6 +67,10 @@ public class ItemDomainMachineDesign extends LocatableStatusItem { private transient String importTemplateAndParameters = null; private transient Float importSortOrder = null; private transient String moveToTrashErrorMsg = null; + private transient MachineAssignedItemChange pendingAssignedItemChange; + private transient MachineRepresentedElementChange pendingRepresentedElementChange; + private transient MachineTrashChange pendingTrashChange; + private transient List pendingMachineAddChanges; private transient String moveToTrashWarningMsg = null; private transient ItemElement currentHierarchyItemElement; @@ -573,6 +581,16 @@ public void setAssignedItem(Item item) { selfElement.setContainedItem2(item); } + @JsonIgnore + public void setPendingAssignedItemChange(Item oldAssignedItem, boolean oldInstalled) { + pendingAssignedItemChange = new MachineAssignedItemChange(oldAssignedItem, oldInstalled); + } + + @JsonIgnore + public MachineAssignedItemChange getPendingAssignedItemChange() { + return pendingAssignedItemChange; + } + @JsonIgnore public String getAssignedItemString() { if (getAssignedItem() != null) { @@ -597,6 +615,42 @@ public void setRepresentsCatalogElement(ItemElement representsCatalogElement) { selfElement.setRepresentsItemElement(representsCatalogElement); } + @JsonIgnore + public void setPendingRepresentedElementChange(ItemElement oldRepresentedElement) { + pendingRepresentedElementChange = new MachineRepresentedElementChange(oldRepresentedElement); + } + + @JsonIgnore + public void setPendingRepresentedElementChange(ItemElement oldRepresentedElement, + ItemDomainMachineDesign sourceTemplate) { + pendingRepresentedElementChange = new MachineRepresentedElementChange( + oldRepresentedElement, sourceTemplate); + } + + @JsonIgnore + public MachineRepresentedElementChange getPendingRepresentedElementChange() { + return pendingRepresentedElementChange; + } + + @JsonIgnore + public void setPendingTrashChange(MachineTrashChange pendingTrashChange) { + this.pendingTrashChange = pendingTrashChange; + } + + @JsonIgnore + public MachineTrashChange getPendingTrashChange() { return pendingTrashChange; } + + @JsonIgnore + public void setPendingMachineAddChanges(List changes) { + pendingMachineAddChanges = changes == null ? null : new ArrayList<>(changes); + } + + @JsonIgnore + public List getPendingMachineAddChanges() { + return pendingMachineAddChanges == null + ? new ArrayList<>() : new ArrayList<>(pendingMachineAddChanges); + } + public boolean isIsHoused() { ItemElement selfElement = getSelfElement(); return selfElement.getIsHoused(); diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/LocatableItem.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/LocatableItem.java index ea47a9e45..d105e5c42 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/LocatableItem.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/entities/LocatableItem.java @@ -5,6 +5,7 @@ package gov.anl.aps.cdb.portal.model.db.entities; import com.fasterxml.jackson.annotation.JsonIgnore; +import gov.anl.aps.cdb.mqtt.metadata.ItemLocationChange; import gov.anl.aps.cdb.portal.controllers.LocatableItemController; import gov.anl.aps.cdb.portal.view.objects.LocationHistoryObject; import java.util.List; @@ -33,6 +34,7 @@ public abstract class LocatableItem extends Item { private transient boolean loadedImportLocationItem = false; private transient boolean loadedImportLocationDetails = false; private transient List locationHistoryListObject = null; + private transient ItemLocationChange pendingLocationChange; // Needed to determine whenever location was removed in edit process. private transient Boolean originalLocationLoaded = false; @@ -245,4 +247,12 @@ public void setLocationHistoryListObject(List locationHis this.locationHistoryListObject = locationHistoryListObject; } + @JsonIgnore + public void setPendingLocationChange(ItemLocationChange pendingLocationChange) { + this.pendingLocationChange = pendingLocationChange; + } + + @JsonIgnore + public ItemLocationChange getPendingLocationChange() { return pendingLocationChange; } + } diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/LogUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/LogUtility.java index f335948fc..260d40688 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/LogUtility.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/LogUtility.java @@ -5,15 +5,25 @@ package gov.anl.aps.cdb.portal.model.db.utilities; import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.mqtt.metadata.ItemLogChange; +import gov.anl.aps.cdb.mqtt.metadata.LogAttachmentChange; import gov.anl.aps.cdb.portal.constants.SystemLogLevel; import gov.anl.aps.cdb.portal.controllers.utilities.LogControllerUtility; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; import gov.anl.aps.cdb.portal.model.db.entities.Log; import gov.anl.aps.cdb.portal.model.db.entities.LogTopic; import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; import gov.anl.aps.cdb.portal.utilities.SearchResult; import gov.anl.aps.cdb.portal.utilities.SessionUtility; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; @@ -53,6 +63,91 @@ public static Log createLogEntry(String logText, LogTopic logTopic) { return logEntry; } + /** + * Compares persisted item log entries by ID with the submitted list. Attachments are + * excluded so attachment-only edits are not reported as log updates. New entries + * (no ID) are handled after persistence since their IDs are DB-assigned. + */ + public static List prepareLogChanges(List originalLogList, List newLogList) { + List changes = new ArrayList<>(); + List originals = originalLogList == null ? Collections.emptyList() : originalLogList; + List submitted = newLogList == null ? Collections.emptyList() : newLogList; + Map submittedById = new HashMap<>(); + for (Log log : submitted) { + if (log.getId() != null) { + submittedById.put(log.getId(), log); + } + } + for (Log original : originals) { + if (original.getId() == null) { + continue; + } + Log log = submittedById.get(original.getId()); + if (log == null) { + changes.add(ItemLogChange.deleted(original)); + } else if (isLogEntryModified(original, log)) { + changes.add(ItemLogChange.updated(original, log)); + } + } + return changes; + } + + /** + * Attachments newly associated with persisted logs: no ID yet, or an ID the persisted + * log does not reference (REST creates the attachment row before linking it). + */ + public static List findAddedAttachments(List originalLogList, + List newLogList) { + List changes = new ArrayList<>(); + if (originalLogList == null || newLogList == null) { + return changes; + } + Map originalsById = new HashMap<>(); + for (Log original : originalLogList) { + if (original.getId() != null) { + originalsById.put(original.getId(), original); + } + } + for (Log log : newLogList) { + Log original = log.getId() == null ? null : originalsById.get(log.getId()); + if (original == null || log.getAttachmentList() == null) { + continue; + } + Set originalIds = new HashSet<>(); + if (original.getAttachmentList() != null) { + for (Attachment attachment : original.getAttachmentList()) { + originalIds.add(attachment.getId()); + } + } + for (Attachment attachment : log.getAttachmentList()) { + if (attachment.getId() == null || !originalIds.contains(attachment.getId())) { + changes.add(new LogAttachmentChange(log, attachment)); + } + } + } + return changes; + } + + private static boolean isLogEntryModified(Log original, Log log) { + return !Objects.equals(original.getText(), log.getText()) + || !sameInstant(original.getEffectiveFromDateTime(), log.getEffectiveFromDateTime()) + || !sameInstant(original.getEffectiveToDateTime(), log.getEffectiveToDateTime()) + || !Objects.equals(original.getLogTopic(), log.getLogTopic()); + } + + // java.sql.Timestamp.equals(Date) is false for the same instant. + private static boolean sameInstant(Date first, Date second) { + return first == null ? second == null : second != null && first.getTime() == second.getTime(); + } + + /** Associates an attachment with a log in memory; the owning entity's update persists it. */ + public static void addAttachment(Log log, Attachment attachment) { + if (log.getAttachmentList() == null) { + log.setAttachmentList(new ArrayList<>()); + } + log.getAttachmentList().add(attachment); + } + public static void searchLogList(List logList, Pattern searchPattern, SearchResult searchResult) { for (Log logEntry : logList) { String baseKey = "log"; diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/PropertyValueUtility.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/PropertyValueUtility.java index 2239b7db0..79db0f7b9 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/PropertyValueUtility.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/db/utilities/PropertyValueUtility.java @@ -4,6 +4,7 @@ */ package gov.anl.aps.cdb.portal.model.db.utilities; +import gov.anl.aps.cdb.mqtt.metadata.PropertyValueChange; import gov.anl.aps.cdb.portal.constants.DisplayType; import gov.anl.aps.cdb.portal.model.db.entities.AllowedPropertyValue; import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; @@ -16,6 +17,7 @@ import gov.anl.aps.cdb.portal.utilities.SearchResult; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -78,14 +80,16 @@ public static List prepareImagePropertyValueList(List originalPropertyValueList, + public static List preparePropertyValueHistory(List originalPropertyValueList, List newPropertyValueList, EntityInfo entityInfo) { + List changes = new ArrayList<>(); for (PropertyValue newPropertyValue : newPropertyValueList) { int index = originalPropertyValueList.indexOf(newPropertyValue); if (index >= 0) { // Original property was there. PropertyValue originalPropertyValue = originalPropertyValueList.get(index); - if (!newPropertyValue.equalsByTagAndValueAndUnitsAndDescriptionAndMetadata(originalPropertyValue)) { + if (!newPropertyValue.equalsByTagAndValueAndUnitsAndDescriptionAndMetadata(originalPropertyValue) + || !Objects.equals(newPropertyValue.getText(), originalPropertyValue.getText())) { // Property value was modified. logger.debug("Property value for type " + originalPropertyValue.getPropertyType() + " was modified (original value: " + originalPropertyValue + "; new value: " + newPropertyValue + ")"); @@ -97,6 +101,7 @@ public static void preparePropertyValueHistory(List originalPrope PropertyValueHistory propertyValueHistory = new PropertyValueHistory(); propertyValueHistory.updateFromPropertyValue(originalPropertyValue); propertyValueHistoryList.add(propertyValueHistory); + changes.add(PropertyValueChange.updated(originalPropertyValue, newPropertyValue)); } } else { // New property value. @@ -104,9 +109,25 @@ public static void preparePropertyValueHistory(List originalPrope + ": " + newPropertyValue); newPropertyValue.setEnteredByUser(entityInfo.getLastModifiedByUser()); newPropertyValue.setEnteredOnDateTime(entityInfo.getLastModifiedOnDateTime()); + changes.add(PropertyValueChange.added(newPropertyValue)); } } + changes.addAll(findRemovedPropertyValues( + originalPropertyValueList, newPropertyValueList)); + return changes; + } + + public static List findRemovedPropertyValues( + List originalPropertyValueList, + List newPropertyValueList) { + List changes = new ArrayList<>(); + for (PropertyValue originalPropertyValue : originalPropertyValueList) { + if (!newPropertyValueList.contains(originalPropertyValue)) { + changes.add(PropertyValueChange.deleted(originalPropertyValue)); + } + } + return changes; } public static PropertyTypeHandlerInterface getPropertyTypeHandler(PropertyValue propertyValue) { diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/jsf/beans/LogAttachmentUploadBean.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/jsf/beans/LogAttachmentUploadBean.java index 35d35bffe..26080ea83 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/jsf/beans/LogAttachmentUploadBean.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/portal/model/jsf/beans/LogAttachmentUploadBean.java @@ -7,6 +7,7 @@ import gov.anl.aps.cdb.portal.model.db.entities.Attachment; import gov.anl.aps.cdb.portal.model.db.entities.Log; import gov.anl.aps.cdb.common.utilities.FileUtility; +import gov.anl.aps.cdb.portal.model.db.utilities.LogUtility; import gov.anl.aps.cdb.portal.utilities.SessionUtility; import gov.anl.aps.cdb.portal.utilities.StorageUtility; import java.io.File; @@ -17,8 +18,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.List; import javax.enterprise.context.SessionScoped; import javax.inject.Named; import org.apache.logging.log4j.LogManager; @@ -68,12 +67,8 @@ public void upload(UploadedFile uploadedFile) { Attachment attachment = new Attachment(); attachment.setName(originalFile.getName()); attachment.setOriginalFilename(fileName); - List attachmentList = logEntry.getAttachmentList(); - if (attachmentList == null) { - attachmentList = new ArrayList<>(); - logEntry.setAttachmentList(attachmentList); - } - attachmentList.add(attachment); + // Saved by the owning entity's update when the dialog's Done runs saveLogList. + LogUtility.addAttachment(logEntry, attachment); SessionUtility.addInfoMessage("Success", "Uploaded file " + fileName + "."); } } catch (IOException ex) { diff --git a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/rest/routes/ItemRoute.java b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/rest/routes/ItemRoute.java index 93861b111..ccee3c064 100644 --- a/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/rest/routes/ItemRoute.java +++ b/src/java/CdbWebPortal/src/java/gov/anl/aps/cdb/rest/routes/ItemRoute.java @@ -18,11 +18,11 @@ import gov.anl.aps.cdb.portal.controllers.utilities.ItemDomainLocationControllerUtility; import gov.anl.aps.cdb.portal.controllers.utilities.ItemDomainMachineDesignControllerUtility; import gov.anl.aps.cdb.portal.controllers.utilities.LocatableItemControllerUtility; +import gov.anl.aps.cdb.portal.controllers.utilities.LogControllerUtility; import gov.anl.aps.cdb.portal.model.db.beans.AttachmentFacade; import gov.anl.aps.cdb.portal.model.db.beans.DomainFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemCategoryFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemElementFacade; -import gov.anl.aps.cdb.portal.model.db.beans.LogFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemProjectFacade; import gov.anl.aps.cdb.portal.model.db.beans.ItemTypeFacade; import gov.anl.aps.cdb.portal.model.db.beans.PropertyTypeFacade; @@ -130,9 +130,6 @@ public class ItemRoute extends ItemBaseRoute { @EJB AttachmentFacade attachmentFacade; - @EJB - LogFacade logFacade; - @GET @Path("/ById/{itemId}") @Produces(MediaType.APPLICATION_JSON) @@ -862,6 +859,65 @@ public Log addLogEntryToItem(@RequestBody(required = true) LogEntryEditInformati return latestLog; } + @POST + @Path("/updateLogEntry/{logId}") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Update text and/or effective date of an existing item log entry.") + @SecurityRequirement(name = "cdbAuth") + @Secured + public Log updateLogEntryForItem( + @PathParam("logId") int logId, + @RequestBody(required = true) LogEntryEditInformation logEntryEditInformation) throws ObjectNotFound, CdbException { + int itemId = logEntryEditInformation.getItemId(); + LOGGER.debug("Updating log " + logId + " for item with id: " + itemId); + Item item = getItemByIdBase(itemId); + UserInfo updateUser = verifyCurrentUserPermissionForItem(item); + Log log = findItemLog(item, logId); + + if (logEntryEditInformation.getLogEntry() != null) { + log.setText(logEntryEditInformation.getLogEntry()); + } + if (logEntryEditInformation.getEffectiveDate() != null) { + log.setEffectiveFromDateTime(logEntryEditInformation.getEffectiveDate()); + } + + ItemControllerUtility controllerUtility = item.getItemControllerUtility(); + Item updatedItem = (Item) controllerUtility.update(item, updateUser); + return findItemLog(updatedItem, logId); + } + + @DELETE + @Path("/deleteLogEntry/{itemId}/{logId}") + @Operation(summary = "Delete an item log entry.") + @SecurityRequirement(name = "cdbAuth") + @Secured + public void deleteLogEntryFromItem( + @PathParam("itemId") int itemId, + @PathParam("logId") int logId) throws ObjectNotFound, CdbException { + LOGGER.debug("Deleting log " + logId + " for item with id: " + itemId); + Item item = getItemByIdBase(itemId); + UserInfo updateUser = verifyCurrentUserPermissionForItem(item); + Log log = findItemLog(item, logId); + + // Mirrors the portal: unlink through the item update, then delete the log row. + item.getLogList().remove(log); + ItemControllerUtility controllerUtility = item.getItemControllerUtility(); + controllerUtility.updateOnRemoval(item, updateUser); + new LogControllerUtility().destroy(log, updateUser); + } + + private Log findItemLog(Item item, int logId) throws ObjectNotFound { + if (item.getLogList() != null) { + for (Log log : item.getLogList()) { + if (log.getId() != null && log.getId() == logId) { + return log; + } + } + } + throw new ObjectNotFound("Could not find log " + logId + " for item " + item.getId()); + } + @POST @Path("/uploadDocument/{itemId}") @Produces(MediaType.APPLICATION_JSON) @@ -968,28 +1024,15 @@ public Log uploadLogAttachmentForItem( LOGGER.debug("Uploading log attachment for item: " + itemId + " log: " + logId); Item item = getItemByIdBase(itemId); UserInfo updatedByUser = verifyCurrentUserPermissionForItem(item); - - Log log = null; - for (Log l : item.getLogList()) { - if (l.getId() != null && l.getId() == logId) { - log = l; - break; - } - } - if (log == null) { - throw new ObjectNotFound("Could not find log " + logId + " for item " + itemId); - } + Log log = findItemLog(item, logId); Attachment attachment = FileUploadUtility.writeLogAttachmentFile( fileUpload.getFileName(), FileUploadUtility.decodeFileUpload(fileUpload)); attachmentFacade.create(attachment); - if (log.getAttachmentList() == null) { - log.setAttachmentList(new ArrayList<>()); - } - log.getAttachmentList().add(attachment); - logFacade.edit(log); - return log; + ItemControllerUtility controllerUtility = item.getItemControllerUtility(); + Item updatedItem = (Item) controllerUtility.addLogAttachment(item, log, attachment, updatedByUser); + return findItemLog(updatedItem, logId); } @GET diff --git a/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogEditListDataTable.xhtml b/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogEditListDataTable.xhtml index 3f61f831c..c9d0123ba 100644 --- a/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogEditListDataTable.xhtml +++ b/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogEditListDataTable.xhtml @@ -38,7 +38,6 @@ See LICENSE file. diff --git a/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogListDataTable.xhtml b/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogListDataTable.xhtml index 4f70d5420..26c2f6264 100644 --- a/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogListDataTable.xhtml +++ b/src/java/CdbWebPortal/web/views/domainEntityLog/private/domainEntityLogListDataTable.xhtml @@ -60,7 +60,6 @@ See LICENSE file. diff --git a/src/lib/mqtt-rar-0.8.0.rar b/src/lib/mqtt-rar-0.8.0.rar new file mode 100644 index 000000000..5f21fdcae Binary files /dev/null and b/src/lib/mqtt-rar-0.8.0.rar differ diff --git a/tools/developer_tools/cdb-base-mqtt/.gitignore b/tools/developer_tools/cdb-base-mqtt/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/developer_tools/cdb-base-mqtt/README.md b/tools/developer_tools/cdb-base-mqtt/README.md new file mode 100644 index 000000000..7a00f06e6 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/README.md @@ -0,0 +1,29 @@ +# CDB Base MQTT + +Shared Java 8 MQTT event base class, publishing API, and Payara JCA/JNDI transport used by ComponentDB and BELY. + +`gov.anl.aps.cdb.base.mqtt.MqttEvent` owns the event timestamp and requires applications to supply a string topic. Application-specific payload fields and topic definitions remain in each portal. + +## Build and test + +A JDK and Maven are required. + +```sh +mvn clean verify +sha256sum -c SHA256SUMS +``` + +The versioned thin JAR is written to `target/cdb-base-mqtt-1.0.0.jar`. Jackson is provided by each consuming portal and is intentionally not bundled. + +GitHub Actions runs `mvn verify` on pushes and pull requests that change this module or its workflow. It also supports manual runs and uploads the Surefire reports as a workflow artifact. + +## Release artifact + +The checked-in `SHA256SUMS` records the released JAR checksum and verifies the artifact under `target/`. After changing this library: + +1. Increment the version in `pom.xml`. +2. Run `mvn clean verify`. +3. Update `SHA256SUMS` with the checksum of the generated JAR. +4. Copy that exact JAR into each portal's checked-in `lib/` directory. +5. Register the versioned JAR in each portal's NetBeans Ant classpath. +6. Verify each copied JAR with `sha256sum` before building the portals. diff --git a/tools/developer_tools/cdb-base-mqtt/SHA256SUMS b/tools/developer_tools/cdb-base-mqtt/SHA256SUMS new file mode 100644 index 000000000..c381ed494 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/SHA256SUMS @@ -0,0 +1 @@ +42a4db3b8f35d7a3785849c0f3365af9f5dec7cffa915899c460c5f90e5e8819 target/cdb-base-mqtt-1.0.0.jar diff --git a/tools/developer_tools/cdb-base-mqtt/pom.xml b/tools/developer_tools/cdb-base-mqtt/pom.xml new file mode 100644 index 000000000..3292ba2fe --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/pom.xml @@ -0,0 +1,56 @@ + + 4.0.0 + + gov.anl.aps + cdb-base-mqtt + 1.0.0 + + + 1.8 + 1.8 + 2026-09-30T00:00:00Z + UTF-8 + 5.10.3 + + + + + com.fasterxml.jackson.core + jackson-annotations + 2.11.2 + provided + + + com.fasterxml.jackson.core + jackson-databind + 2.11.2 + provided + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 8 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttEvent.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttEvent.java new file mode 100644 index 000000000..6a3482bc1 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttEvent.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.Date; + +/** Base for an application-owned payload and its destination topic. */ +public abstract class MqttEvent { + private final Date eventTimestamp; + + protected MqttEvent() { + eventTimestamp = new Date(); + } + + @JsonIgnore + public abstract String getTopic(); + + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Date getEventTimestamp() { + return new Date(eventTimestamp.getTime()); + } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttPublisher.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttPublisher.java new file mode 100644 index 000000000..873d73a07 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/MqttPublisher.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt; + +import gov.anl.aps.cdb.base.mqtt.exception.MqttException; + +public interface MqttPublisher { + void publish(MqttEvent event) throws MqttException; +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttException.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttException.java new file mode 100644 index 000000000..91982bbe0 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttException.java @@ -0,0 +1,10 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.exception; + +public abstract class MqttException extends Exception { + protected MqttException(String message) { super(message); } + protected MqttException(String message, Throwable cause) { super(message, cause); } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttNotConfiguredException.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttNotConfiguredException.java new file mode 100644 index 000000000..dbefec5be --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttNotConfiguredException.java @@ -0,0 +1,10 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.exception; + +public class MqttNotConfiguredException extends MqttException { + public MqttNotConfiguredException(String message) { super(message); } + public MqttNotConfiguredException(String message, Throwable cause) { super(message, cause); } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttPublishException.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttPublishException.java new file mode 100644 index 000000000..529ea5025 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttPublishException.java @@ -0,0 +1,9 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.exception; + +public class MqttPublishException extends MqttException { + public MqttPublishException(String message, Throwable cause) { super(message, cause); } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttSerializationException.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttSerializationException.java new file mode 100644 index 000000000..19ad831ed --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/exception/MqttSerializationException.java @@ -0,0 +1,9 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.exception; + +public class MqttSerializationException extends MqttException { + public MqttSerializationException(String message, Throwable cause) { super(message, cause); } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JcaMqttPublisher.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JcaMqttPublisher.java new file mode 100644 index 000000000..76bac90f1 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JcaMqttPublisher.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.jca; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.base.mqtt.MqttPublisher; +import gov.anl.aps.cdb.base.mqtt.exception.MqttException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttPublishException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttSerializationException; +import java.nio.charset.StandardCharsets; + +public class JcaMqttPublisher implements MqttPublisher { + private final MqttConnectionProvider connections; + private final ObjectMapper mapper; + private final int qos; + private final boolean retained; + + public JcaMqttPublisher(MqttConnectionProvider connections, ObjectMapper mapper) { this(connections, mapper, 0, false); } + public JcaMqttPublisher(MqttConnectionProvider connections, ObjectMapper mapper, int qos, boolean retained) { + if (connections == null || mapper == null) throw new IllegalArgumentException("connections and mapper are required"); + if (qos < 0 || qos > 2) throw new IllegalArgumentException("qos must be between 0 and 2"); + this.connections = connections; this.mapper = mapper; this.qos = qos; this.retained = retained; + } + + @Override public void publish(MqttEvent event) throws MqttException { + if (event == null || event.getTopic() == null || event.getTopic().trim().isEmpty()) throw new IllegalArgumentException("event topic is required"); + final byte[] payload; + try { payload = mapper.writeValueAsString(event).getBytes(StandardCharsets.UTF_8); } + catch (JsonProcessingException ex) { throw new MqttSerializationException("Failed to serialize MQTT event", ex); } + try (MqttConnection connection = connections.getConnection()) { + connection.publish(event.getTopic(), payload, qos, retained); + } catch (MqttException ex) { throw ex; } + catch (Exception ex) { throw new MqttPublishException("Failed to publish MQTT event to " + event.getTopic(), ex); } + } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JndiMqttConnectionProvider.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JndiMqttConnectionProvider.java new file mode 100644 index 000000000..cfd83e7e8 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/JndiMqttConnectionProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.jca; + +import gov.anl.aps.cdb.base.mqtt.exception.MqttNotConfiguredException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import javax.naming.InitialContext; +import javax.naming.NamingException; + +/** Looks up a Payara MQTT JCA factory without binding consumers to its API version. */ +public class JndiMqttConnectionProvider implements MqttConnectionProvider { + private final String jndiName; + + public JndiMqttConnectionProvider(String jndiName) { + if (jndiName == null || jndiName.trim().isEmpty()) throw new IllegalArgumentException("jndiName is required"); + this.jndiName = jndiName; + } + + @Override public MqttConnection getConnection() throws MqttNotConfiguredException { + try { + Object factory = new InitialContext().lookup(jndiName); + Object connection = factory.getClass().getMethod("getConnection").invoke(factory); + if (connection == null) { + throw new MqttNotConfiguredException( + "MQTT resource could not connect to its broker: " + jndiName, null); + } + return new ReflectiveConnection(connection); + } catch (NamingException | ReflectiveOperationException | LinkageError ex) { + throw new MqttNotConfiguredException("MQTT resource is unavailable: " + jndiName, unwrap(ex)); + } + } + + private static Throwable unwrap(Throwable error) { + return error instanceof InvocationTargetException && error.getCause() != null ? error.getCause() : error; + } + + private static final class ReflectiveConnection implements MqttConnection { + private final Object delegate; + private final Method publish; + private final Method close; + ReflectiveConnection(Object delegate) throws NoSuchMethodException { + this.delegate = delegate; + publish = delegate.getClass().getMethod("publish", String.class, byte[].class, int.class, boolean.class); + close = delegate.getClass().getMethod("close"); + } + @Override public void publish(String topic, byte[] payload, int qos, boolean retained) throws Exception { + try { publish.invoke(delegate, topic, payload, qos, retained); } + catch (InvocationTargetException ex) { throw asException(unwrap(ex)); } + } + @Override public void close() throws Exception { + try { close.invoke(delegate); } + catch (InvocationTargetException ex) { throw asException(unwrap(ex)); } + } + private static Exception asException(Throwable error) { + return error instanceof Exception ? (Exception) error : new Exception(error); + } + } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnection.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnection.java new file mode 100644 index 000000000..19741d331 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnection.java @@ -0,0 +1,10 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.jca; + +public interface MqttConnection extends AutoCloseable { + void publish(String topic, byte[] payload, int qos, boolean retained) throws Exception; + @Override void close() throws Exception; +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnectionProvider.java b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnectionProvider.java new file mode 100644 index 000000000..e2209bfa9 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/main/java/gov/anl/aps/cdb/base/mqtt/jca/MqttConnectionProvider.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt.jca; + +import gov.anl.aps.cdb.base.mqtt.exception.MqttNotConfiguredException; + +public interface MqttConnectionProvider { + MqttConnection getConnection() throws MqttNotConfiguredException; +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JcaMqttPublisherTest.java b/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JcaMqttPublisherTest.java new file mode 100644 index 000000000..9cb4142b7 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JcaMqttPublisherTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.base.mqtt.exception.MqttNotConfiguredException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttPublishException; +import gov.anl.aps.cdb.base.mqtt.exception.MqttSerializationException; +import gov.anl.aps.cdb.base.mqtt.jca.JcaMqttPublisher; +import gov.anl.aps.cdb.base.mqtt.jca.MqttConnection; +import gov.anl.aps.cdb.base.mqtt.jca.MqttConnectionProvider; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class JcaMqttPublisherTest { + @Test void publishesConfiguredArgumentsAndCloses() throws Exception { + FakeConnection connection = new FakeConnection(); + JcaMqttPublisher publisher = new JcaMqttPublisher(() -> connection, new ObjectMapper(), 1, true); + publisher.publish(new Event("value")); + assertEquals("cdb/add", connection.topic); + String payload = new String(connection.payload, StandardCharsets.UTF_8); + assertTrue(payload.contains("\"value\":\"value\"")); + assertTrue(payload.contains("\"eventTimestamp\":")); + assertFalse(payload.contains("\"topic\":")); + assertEquals(1, connection.qos); assertTrue(connection.retained); assertTrue(connection.closed); + } + @Test void reportsMissingConfiguration() { + MqttConnectionProvider provider = () -> { throw new MqttNotConfiguredException("missing"); }; + assertThrows(MqttNotConfiguredException.class, () -> new JcaMqttPublisher(provider, new ObjectMapper()).publish(new Event("x"))); + } + @Test void reportsSerializationFailureWithoutOpeningConnection() { + boolean[] opened = {false}; + ObjectMapper mapper = new ObjectMapper() { @Override public String writeValueAsString(Object value) throws com.fasterxml.jackson.core.JsonProcessingException { throw new com.fasterxml.jackson.core.JsonProcessingException("bad") {}; } }; + assertThrows(MqttSerializationException.class, () -> new JcaMqttPublisher(() -> { opened[0]=true; return new FakeConnection(); }, mapper).publish(new Event("x"))); + assertFalse(opened[0]); + } + @Test void closesAfterPublishFailure() { + FakeConnection connection = new FakeConnection(); connection.failPublish = true; + assertThrows(MqttPublishException.class, () -> new JcaMqttPublisher(() -> connection, new ObjectMapper()).publish(new Event("x"))); + assertTrue(connection.closed); + } + @Test void rejectsInvalidQos() { assertThrows(IllegalArgumentException.class, () -> new JcaMqttPublisher(() -> null, new ObjectMapper(), 3, false)); } + + static final class Event extends MqttEvent { + private final String value; Event(String value){this.value=value;} + @Override public String getTopic(){return "cdb/add";} public String getValue(){return value;} + } + static final class FakeConnection implements MqttConnection { + String topic; byte[] payload; int qos; boolean retained; boolean closed; boolean failPublish; + @Override public void publish(String topic, byte[] payload, int qos, boolean retained) throws Exception { this.topic=topic;this.payload=payload;this.qos=qos;this.retained=retained;if(failPublish)throw new Exception("broker"); } + @Override public void close(){closed=true;} + } +} diff --git a/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JndiMqttConnectionProviderTest.java b/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JndiMqttConnectionProviderTest.java new file mode 100644 index 000000000..60d5a0830 --- /dev/null +++ b/tools/developer_tools/cdb-base-mqtt/src/test/java/gov/anl/aps/cdb/base/mqtt/JndiMqttConnectionProviderTest.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.base.mqtt; + +import gov.anl.aps.cdb.base.mqtt.exception.MqttNotConfiguredException; +import gov.anl.aps.cdb.base.mqtt.jca.JndiMqttConnectionProvider; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class JndiMqttConnectionProviderTest { + @Test void missingJndiResourceIsReportedAsNotConfigured() { + assertThrows(MqttNotConfiguredException.class, () -> new JndiMqttConnectionProvider("java:comp/env/missing-mqtt").getConnection()); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/pom.xml b/tools/developer_tools/code_testing/CdbWebPortalTest/pom.xml index 9545f919f..c63b0f0e9 100644 --- a/tools/developer_tools/code_testing/CdbWebPortalTest/pom.xml +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/pom.xml @@ -66,6 +66,20 @@ + + gov.anl.aps.cdb + cdb-base-mqtt + 1.0.0 + system + ${project.basedir}/../../../../src/java/CdbWebPortal/lib/cdb-base-mqtt-1.0.0.jar + + + gov.anl.aps.mqtt + mqtt-jca-api + 1.0.0 + system + ${project.basedir}/../../../../src/java/CdbWebPortal/lib/mqtt-jca-api-1.0.0.jar + junit junit diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEventTest.java new file mode 100644 index 000000000..c5142e027 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbInventoryAssemblyElementEventTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventory; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbInventoryAssemblyElementEventTest { + + @Test + public void serializesAssemblySlotAndAssignmentTransition() throws Exception { + ItemDomainInventory assembly = inventory(1, "Assembly"); + ItemDomainInventory oldItem = inventory(2, "Old"); + ItemDomainInventory newItem = inventory(3, "New"); + ItemDomainCatalog catalogAssembly = catalog(4, "Catalog Assembly"); + ItemDomainCatalog catalogPart = catalog(5, "Catalog Part"); + ItemElement catalogElement = element(6, catalogAssembly, catalogPart); + ItemElement assemblyElement = element(7, assembly, newItem); + assemblyElement.setDerivedFromItemElement(catalogElement); + + CdbInventoryAssemblyElementEvent event = new CdbInventoryAssemblyElementEvent( + assembly, assemblyElement, oldItem, newItem, null); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/inventory/assembly-element/update", event.getTopic()); + assertEquals("1", event.getInventoryAssembly().getId()); + assertEquals("7", event.getAssemblyElement().getId()); + assertEquals("6", event.getCatalogElement().getId()); + assertEquals("2", event.getOldAssignedInventoryItem().getId()); + assertEquals("3", event.getNewAssignedInventoryItem().getId()); + assertFalse(json.contains("\"topic\"")); + } + + private static ItemElement element(int id, Item parent, Item contained) { + ItemElement element = new ItemElement(); + element.setId(id); + element.setName("E" + id); + element.setParentItem(parent); + element.setContainedItem(contained); + return element; + } + + private static ItemDomainInventory inventory(int id, String name) { + ItemDomainInventory item = new ItemDomainInventory(); + initialize(item, id, name, 2, "inventory"); + return item; + } + + private static ItemDomainCatalog catalog(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + initialize(item, id, name, 1, "catalog"); + return item; + } + + private static void initialize(Item item, int id, String name, int domainId, String domain) { + item.setId(id); + item.setName(name); + item.setDomain(new Domain(domainId, domain)); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEventTest.java new file mode 100644 index 000000000..8b6d1bbe4 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLocationEventTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventory; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainLocation; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbItemLocationEventTest { + + @Test + public void serializesLocationAndDetailsTransition() throws Exception { + ItemDomainInventory affected = inventory(1, "Device"); + ItemDomainLocation oldLocation = location(2, "Old"); + ItemDomainLocation newLocation = location(3, "New"); + CdbItemLocationEvent event = new CdbItemLocationEvent(affected, + oldLocation, newLocation, "rack 1", "rack 2", null); + + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/item/location/update", event.getTopic()); + assertEquals("1", event.getItem().getId()); + assertEquals("2", event.getOldLocation().getId()); + assertEquals("3", event.getNewLocation().getId()); + assertEquals("rack 1", event.getOldLocationDetails()); + assertEquals("rack 2", event.getNewLocationDetails()); + assertFalse(json.contains("\"topic\"")); + } + + @Test + public void supportsSetAndClearTransitions() { + ItemDomainInventory affected = inventory(1, "Device"); + ItemDomainLocation location = location(2, "Location"); + assertNull(new CdbItemLocationEvent(affected, null, location, + null, null, null).getOldLocation()); + assertNull(new CdbItemLocationEvent(affected, location, null, + null, null, null).getNewLocation()); + } + + private static ItemDomainInventory inventory(int id, String name) { + ItemDomainInventory item = new ItemDomainInventory(); + initialize(item, id, name, 2, "inventory"); + return item; + } + + private static ItemDomainLocation location(int id, String name) { + ItemDomainLocation item = new ItemDomainLocation(); + initialize(item, id, name, 3, "location"); + return item; + } + + private static void initialize(Item item, int id, String name, int domainId, String domain) { + item.setId(id); + item.setName(name); + item.setDomain(new Domain(domainId, domain)); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEventTest.java new file mode 100644 index 000000000..ddc123112 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogAttachmentEventTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbItemLogAttachmentEventTest { + + @Test + public void serializesLogIdAndAttachmentNamesOnly() throws Exception { + Attachment attachment = new Attachment(5, "attachment.123.pdf"); + attachment.setOriginalFilename("report.pdf"); + attachment.setTag("internal-tag"); + Log log = new Log(); + log.setId(7); + UserInfo user = new UserInfo(); + user.setUsername("operator"); + + CdbItemLogAttachmentEvent event = new CdbItemLogAttachmentEvent(item(), log, attachment, user); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/item/log/attachment/add", event.getTopic()); + assertEquals(CdbMqttOperation.ADD, event.getOperation()); + assertEquals("1", event.getItemId()); + assertEquals("ItemDomainCatalog", event.getItemType()); + assertEquals("7", event.getLogId()); + assertEquals("attachment.123.pdf", event.getAttachment().getName()); + assertEquals("report.pdf", event.getAttachment().getOriginalFilename()); + assertEquals("operator", event.getTriggeredByUsername()); + assertTrue(json.contains("\"operation\":\"add\"")); + assertTrue(json.contains("\"eventTimestamp\"")); + assertFalse(json.contains("\"topic\"")); + // DB id, tag and paths stay server-side. + assertFalse(json.contains("attachmentId")); + assertFalse(json.contains("internal-tag")); + assertFalse(json.contains("filePath")); + } + + @Test(expected = IllegalArgumentException.class) + public void requiresAttachment() { + Log log = new Log(); + log.setId(7); + new CdbItemLogAttachmentEvent(item(), log, null, null); + } + + private static ItemDomainCatalog item() { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + item.setName("Item"); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogEventTest.java new file mode 100644 index 000000000..3756094aa --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemLogEventTest.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.LogTopic; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbItemLogEventTest { + + @Test + public void serializesAddedLogWithItemContextAndNoAttachments() throws Exception { + Log log = log(7, "First entry"); + log.setLogTopic(new LogTopic(3, "Maintenance")); + Attachment attachment = new Attachment(); + attachment.setOriginalFilename("secret.pdf"); + log.setAttachmentList(new ArrayList<>(Collections.singletonList(attachment))); + + CdbItemLogEvent event = CdbItemLogEvent.added(item(), log, user()); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/item/log/add", event.getTopic()); + assertEquals(CdbMqttOperation.ADD, event.getOperation()); + assertEquals("1", event.getItemId()); + assertEquals("ItemDomainCatalog", event.getItemType()); + assertNull(event.getOldLog()); + assertEquals("7", event.getNewLog().getLogId()); + assertEquals("First entry", event.getNewLog().getText()); + assertFalse(event.getNewLog().isTextOmitted()); + assertEquals("author", event.getNewLog().getEnteredByUsername()); + assertEquals("3", event.getNewLog().getLogTopicId()); + assertEquals("Maintenance", event.getNewLog().getLogTopicName()); + assertNull(event.getTextDiff()); + assertFalse(event.isDiffTruncated()); + assertEquals("operator", event.getTriggeredByUsername()); + assertTrue(json.contains("\"operation\":\"add\"")); + assertTrue(json.contains("\"enteredOnDateTime\":\"")); + assertFalse(json.contains("\"topic\"")); + assertFalse(json.contains("attachment")); + assertFalse(json.contains("secret.pdf")); + } + + @Test + public void updateCarriesLineDiffAndOmitsFullText() { + Log oldLog = log(7, "line one\nline two"); + Log newLog = log(7, "line one\nline 2"); + + CdbItemLogEvent event = CdbItemLogEvent.updated(item(), oldLog, newLog, user()); + + assertEquals("cdb/item/log/update", event.getTopic()); + assertEquals("-line two\n+line 2\n", event.getTextDiff()); + assertTrue(event.getOldLog().isTextOmitted()); + assertNull(event.getOldLog().getText()); + assertNull(event.getNewLog().getText()); + assertFalse(event.isDiffTruncated()); + } + + @Test + public void updateWithUnchangedTextHasNullDiff() { + Log oldLog = log(7, "same"); + Log newLog = log(7, "same"); + newLog.setEffectiveFromDateTime(new Date(5000)); + + CdbItemLogEvent event = CdbItemLogEvent.updated(item(), oldLog, newLog, user()); + + assertNull(event.getTextDiff()); + assertEquals(5000, event.getNewLog().getEffectiveFromDateTime().getTime()); + } + + @Test + public void deleteCarriesOldLogOnly() { + CdbItemLogEvent event = CdbItemLogEvent.deleted(item(), log(7, "gone"), user()); + + assertEquals("cdb/item/log/delete", event.getTopic()); + assertEquals(CdbMqttOperation.DELETE, event.getOperation()); + assertEquals("gone", event.getOldLog().getText()); + assertNull(event.getNewLog()); + } + + @Test + public void longTextIsBoundedAndFlagged() { + String text = new String(new char[CdbItemLogEvent.MAX_TEXT_LENGTH + 10]).replace('\0', 'x'); + + CdbItemLogEvent added = CdbItemLogEvent.added(item(), log(7, text), user()); + CdbItemLogEvent updated = CdbItemLogEvent.updated(item(), log(7, "a"), log(7, text), user()); + + assertEquals(CdbItemLogEvent.MAX_TEXT_LENGTH, added.getNewLog().getText().length()); + assertTrue(added.isDiffTruncated()); + assertEquals(CdbItemLogEvent.MAX_TEXT_LENGTH, updated.getTextDiff().length()); + assertTrue(updated.isDiffTruncated()); + } + + @Test(expected = IllegalArgumentException.class) + public void requiresItem() { + CdbItemLogEvent.added(null, log(7, "x"), null); + } + + private static Log log(Integer id, String text) { + Log log = new Log(); + log.setId(id); + log.setText(text); + UserInfo author = new UserInfo(); + author.setUsername("author"); + log.setEnteredByUser(author); + log.setEnteredOnDateTime(new Date(1000)); + log.setEffectiveFromDateTime(new Date(1000)); + return log; + } + + private static ItemDomainCatalog item() { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + item.setName("Item"); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static UserInfo user() { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + return user; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEventTest.java new file mode 100644 index 000000000..7f2ab529d --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbItemPropertyEventTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyMetadata; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyType; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyTypeHandler; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyValue; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import gov.anl.aps.cdb.portal.model.jsf.handlers.MarkdownPropertyTypeHandler; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbItemPropertyEventTest { + + @Test + public void serializesScalarAddedPropertyWithoutTopic() throws Exception { + ItemDomainCatalog item = item(10, "Magnet"); + UserInfo user = new UserInfo(); + user.setUsername("operator"); + PropertyValue value = property(20, 30, "serial", "A1"); + value.setUnits("unit"); + value.setDescription("description"); + PropertyMetadata metadata = new PropertyMetadata(1, "source", "manual"); + metadata.setPropertyValue(value); + value.setPropertyMetadataList(Collections.singletonList(metadata)); + + CdbItemPropertyEvent event = CdbItemPropertyEvent.added(item, value, user); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/item/property/add", event.getTopic()); + assertEquals(CdbMqttOperation.ADD, event.getOperation()); + assertEquals("10", event.getItemId()); + assertEquals("operator", event.getTriggeredByUsername()); + assertEquals("A1", event.getNewProperty().getValue()); + assertEquals("manual", event.getNewProperty().getMetadata().get("source")); + assertNull(event.getOldProperty()); + assertFalse(json.contains("\"topic\"")); + assertTrue(json.contains("\"eventTimestamp\"")); + } + + @Test + public void longTextUpdateContainsChangedLineDiffInsteadOfFullText() { + ItemDomainCatalog item = item(10, "Magnet"); + PropertyValue oldValue = markdown(20, "same\nold\ntail"); + PropertyValue newValue = markdown(20, "same\nnew\ntail"); + + CdbItemPropertyEvent event = CdbItemPropertyEvent.updated( + item, oldValue, newValue, null); + + assertEquals("-old\n+new\n", event.getTextDiff()); + assertNull(event.getOldProperty().getText()); + assertNull(event.getNewProperty().getText()); + assertTrue(event.getOldProperty().isTextOmitted()); + assertTrue(event.getNewProperty().isTextOmitted()); + assertFalse(event.isDiffTruncated()); + } + + @Test + public void capsLongTextPayloadsAndReportsTruncation() { + PropertyValue value = markdown(20, + String.join("", Collections.nCopies(CdbItemPropertyEvent.MAX_TEXT_LENGTH + 1, "x"))); + + CdbItemPropertyEvent event = CdbItemPropertyEvent.added(item(10, "Magnet"), value, null); + + assertEquals(CdbItemPropertyEvent.MAX_TEXT_LENGTH, + event.getNewProperty().getText().length()); + assertFalse(event.getNewProperty().isTextOmitted()); + assertTrue(event.isDiffTruncated()); + } + + @Test + public void deleteCarriesOnlyOldProperty() { + CdbItemPropertyEvent event = CdbItemPropertyEvent.deleted( + item(10, "Magnet"), property(20, 30, "serial", "A1"), null); + assertEquals("cdb/item/property/delete", event.getTopic()); + assertNotNull(event.getOldProperty()); + assertNull(event.getNewProperty()); + } + + private static PropertyValue markdown(int id, String text) { + PropertyValue value = property(id, 30, "notes", null); + value.setText(text); + value.getPropertyType().setPropertyTypeHandler( + new PropertyTypeHandler(1, MarkdownPropertyTypeHandler.HANDLER_NAME)); + return value; + } + + private static PropertyValue property(int id, int typeId, String typeName, String valueText) { + PropertyType type = new PropertyType(typeId, typeName); + PropertyValue value = new PropertyValue(); + value.setId(id); + value.setPropertyType(type); + value.setValue(valueText); + value.setPropertyMetadataList(new ArrayList<>()); + return value; + } + + private static ItemDomainCatalog item(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(id); + item.setName(name); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEventTest.java new file mode 100644 index 000000000..dc9f27632 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAddEventTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.mqtt.metadata.MachineAddChange; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.EntityType; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMachineAddEventTest { + + @Test + public void serializesMachineParentAndActorWithoutTopicProperty() throws Exception { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + ItemDomainMachineDesign added = machine(1, "Added"); + added.setEntityTypeListFromApi(Collections.singletonList(new EntityType(9, "ioc"))); + CdbMachineAddEvent event = new CdbMachineAddEvent(added, machine(2, "Parent"), user); + + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/machine-design/add", event.getTopic()); + assertEquals("1", event.getMachine().getId()); + assertEquals("machineDesign", event.getMachine().getDomain()); + assertEquals("ioc", event.getMachine().getEntityTypes().get(0).getName()); + assertEquals("2", event.getParent().getId()); + assertEquals("operator", event.getTriggeredByUsername()); + assertFalse(json.contains("\"topic\"")); + assertFalse(json.contains("oldParent")); + assertFalse(json.contains("newParent")); + assertTrue(json.contains("\"parent\"")); + assertTrue(json.contains("\"eventTimestamp\"")); + } + + @Test + public void rootMachineHasNullParent() throws Exception { + CdbMachineAddEvent event = new CdbMachineAddEvent(machine(1, "Root"), null, null); + + assertNull(event.getParent()); + assertNull(event.getTriggeredByUsername()); + assertTrue(new ObjectMapper().writeValueAsString(event).contains("\"parent\":null")); + } + + @Test + public void omitsTemplateFieldsForNonTemplateMachine() throws Exception { + String json = new ObjectMapper().writeValueAsString( + new CdbMachineAddEvent(machine(1, "Plain"), null, null)); + + assertFalse(json.contains("sourceTemplate")); + assertFalse(json.contains("templateAppliedToMachine")); + assertFalse(json.contains("templateCreatedMachineCount")); + } + + @Test + public void serializesTemplateContext() throws Exception { + MachineAddChange change = new MachineAddChange(machine(3, "Child"), machine(2, "Instance"), + machine(10, "Child template"), machine(2, "Instance"), 4); + CdbMachineAddEvent event = new CdbMachineAddEvent(change, null); + + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("10", event.getSourceTemplate().getId()); + assertEquals("2", event.getTemplateAppliedToMachine().getId()); + assertEquals(Integer.valueOf(4), event.getTemplateCreatedMachineCount()); + assertTrue(json.contains("\"sourceTemplate\":{")); + assertTrue(json.contains("\"templateAppliedToMachine\":{")); + assertTrue(json.contains("\"templateCreatedMachineCount\":4")); + } + + @Test(expected = IllegalArgumentException.class) + public void requiresMachine() { + new CdbMachineAddEvent(null, null, null); + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setDomain(new Domain(4, "machineDesign")); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEventTest.java new file mode 100644 index 000000000..22335f916 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineAssignedItemEventTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMachineAssignedItemEventTest { + + @Test + public void serializesAssignTransitionWithoutTopicProperty() throws Exception { + ItemDomainMachineDesign machine = machine(1, "M1"); + Item assigned = catalog(2, "Catalog"); + UserInfo user = new UserInfo(); + user.setUsername("operator"); + + CdbMachineAssignedItemEvent event = new CdbMachineAssignedItemEvent( + machine, null, assigned, true, false, user); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/machine-design/assigned-item/update", event.getTopic()); + assertEquals("1", event.getMachine().getId()); + assertEquals("machineDesign", event.getMachine().getDomain()); + assertEquals(Integer.valueOf(1), event.getMachine().getDomainId()); + assertNull(event.getOldAssignedItem()); + assertEquals("2", event.getNewAssignedItem().getId()); + assertEquals("catalog", event.getNewAssignedItem().getDomain()); + assertTrue(json.contains("\"domain_id\":1")); + assertTrue(event.isOldInstalled()); + assertFalse(event.isNewInstalled()); + assertEquals("operator", event.getTriggeredByUsername()); + assertFalse(json.contains("\"topic\"")); + } + + @Test + public void supportsUnassignTransition() { + CdbMachineAssignedItemEvent event = new CdbMachineAssignedItemEvent( + machine(1, "M1"), catalog(2, "Catalog"), null, + false, true, null); + assertEquals("2", event.getOldAssignedItem().getId()); + assertNull(event.getNewAssignedItem()); + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + initialize(item, id, name); + return item; + } + + private static ItemDomainCatalog catalog(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + initialize(item, id, name); + return item; + } + + private static void initialize(Item item, int id, String name) { + item.setId(id); + item.setName(name); + item.setDomain(new Domain(1, item instanceof ItemDomainMachineDesign + ? "machineDesign" : "catalog")); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEventTest.java new file mode 100644 index 000000000..292ea5119 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineMoveEventTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.EntityType; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMachineMoveEventTest { + + @Test + public void serializesMoveContextAndActorWithoutTopicProperty() throws Exception { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + ItemDomainMachineDesign moved = machine(1, "Moved"); + moved.setEntityTypeListFromApi(Collections.singletonList(new EntityType(9, "ioc"))); + CdbMachineMoveEvent event = new CdbMachineMoveEvent( + moved, machine(2, "Old"), machine(3, "New"), user); + + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/machine-design/move", event.getTopic()); + assertEquals("1", event.getMachine().getId()); + assertEquals("2", event.getOldParent().getId()); + assertEquals("3", event.getNewParent().getId()); + assertEquals("machineDesign", event.getMachine().getDomain()); + assertEquals(Integer.valueOf(9), event.getMachine().getEntityTypes().get(0).getId()); + assertEquals("ioc", event.getMachine().getEntityTypes().get(0).getName()); + assertEquals("operator", event.getTriggeredByUsername()); + assertFalse(json.contains("\"topic\"")); + assertTrue(json.contains("\"eventTimestamp\"")); + } + + @Test + public void supportsMoveFromTopLevel() { + CdbMachineMoveEvent event = new CdbMachineMoveEvent( + machine(1, "Moved"), null, machine(3, "New"), null); + assertNull(event.getOldParent()); + assertNull(event.getTriggeredByUsername()); + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setDomain(new Domain(4, "machineDesign")); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEventTest.java new file mode 100644 index 000000000..ff2687d28 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineRepresentedElementEventTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMachineRepresentedElementEventTest { + + @Test + public void serializesElementAndCatalogContext() throws Exception { + ItemDomainMachineDesign machine = machine(1, "Machine"); + ItemDomainCatalog assembly = catalog(2, "Assembly"); + ItemDomainCatalog part = catalog(3, "Part"); + ItemElement element = element(4, "E1", assembly, part); + + CdbMachineRepresentedElementEvent event + = new CdbMachineRepresentedElementEvent(machine, null, element, null); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/machine-design/represented-element/update", event.getTopic()); + assertNull(event.getOldRepresentedElement()); + assertEquals("4", event.getNewRepresentedElement().getId()); + assertEquals("2", event.getNewRepresentedElement().getParentItem().getId()); + assertEquals("3", event.getNewRepresentedElement().getContainedItem().getId()); + assertNull(event.getSourceTemplate()); + assertFalse(json.contains("\"topic\"")); + } + + @Test + public void includesTemplateContextForPropagatedChange() { + CdbMachineRepresentedElementEvent event = new CdbMachineRepresentedElementEvent( + machine(1, "Instance"), null, null, machine(2, "Template"), null); + assertEquals("2", event.getSourceTemplate().getId()); + } + + private static ItemElement element(int id, String name, Item parent, Item contained) { + ItemElement element = new ItemElement(); + element.setId(id); + element.setName(name); + element.setParentItem(parent); + element.setContainedItem(contained); + return element; + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + initialize(item, id, name, 4, "machineDesign"); + return item; + } + + private static ItemDomainCatalog catalog(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + initialize(item, id, name, 1, "catalog"); + return item; + } + + private static void initialize(Item item, int id, String name, int domainId, String domain) { + item.setId(id); + item.setName(name); + item.setDomain(new Domain(domainId, domain)); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEventTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEventTest.java new file mode 100644 index 000000000..cb01865ea --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/CdbMachineTrashEventTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gov.anl.aps.cdb.portal.model.db.entities.Domain; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMachineTrashEventTest { + + @Test + public void serializesHierarchyTrashContext() throws Exception { + CdbMachineTrashEvent event = new CdbMachineTrashEvent( + CdbMqttTopic.MACHINE_DESIGN_TRASH, + machine(2, "Child"), machine(1, "Root"), 3, null); + String json = new ObjectMapper().writeValueAsString(event); + + assertEquals("cdb/machine-design/trash", event.getTopic()); + assertEquals(CdbMqttOperation.TRASH, event.getOperation()); + assertEquals("2", event.getAffectedMachine().getId()); + assertEquals("1", event.getRequestedHierarchyRoot().getId()); + assertEquals(3, event.getAffectedMachineCount()); + assertFalse(json.contains("\"topic\"")); + } + + @Test + public void supportsRestoreOperation() { + CdbMachineTrashEvent event = new CdbMachineTrashEvent( + CdbMqttTopic.MACHINE_DESIGN_RESTORE, + machine(1, "Root"), machine(1, "Root"), 1, null); + assertEquals(CdbMqttOperation.RESTORE, event.getOperation()); + assertEquals("cdb/machine-design/restore", event.getTopic()); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsUnrelatedTopic() { + new CdbMachineTrashEvent(CdbMqttTopic.UPDATE, + machine(1, "Root"), machine(1, "Root"), 1, null); + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setDomain(new Domain(4, "machineDesign")); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtilityTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtilityTest.java new file mode 100644 index 000000000..bff4e12e1 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/mqtt/utilities/CdbMqttTextUtilityTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.mqtt.utilities; + +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbMqttTextUtilityTest { + + @Test + public void lineDiffKeepsOnlyChangedMiddleLines() { + assertEquals("-b\n+B\n+c2\n", + CdbMqttTextUtility.createLineDiff("a\nb\nd", "a\nB\nc2\nd")); + } + + @Test + public void lineDiffHandlesNullAndCrLf() { + assertEquals("+x\n", CdbMqttTextUtility.createLineDiff(null, "x")); + assertEquals("-x\n", CdbMqttTextUtility.createLineDiff("x", "")); + assertEquals("", CdbMqttTextUtility.createLineDiff("a\r\nb", "a\nb")); + } + + @Test + public void truncatesOnlyAboveLimit() { + String atLimit = String.join("", Collections.nCopies(CdbMqttTextUtility.MAX_TEXT_LENGTH, "x")); + String overLimit = atLimit + "y"; + + assertSame(atLimit, CdbMqttTextUtility.truncate(atLimit)); + assertFalse(CdbMqttTextUtility.exceedsLimit(atLimit)); + assertTrue(CdbMqttTextUtility.exceedsLimit(overLimit)); + assertEquals(atLimit, CdbMqttTextUtility.truncate(overLimit)); + assertNull(CdbMqttTextUtility.truncate(null)); + assertEquals(0, CdbMqttTextUtility.length(null)); + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtilityEventLifecycleTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtilityEventLifecycleTest.java new file mode 100644 index 000000000..bea52e0d5 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CdbEntityControllerUtilityEventLifecycleTest.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.mqtt.CdbEntityEvent; +import gov.anl.aps.cdb.portal.constants.SystemLogLevel; +import gov.anl.aps.cdb.portal.model.db.beans.CdbEntityFacade; +import gov.anl.aps.cdb.portal.model.db.entities.CdbEntity; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.persistence.EntityManager; +import org.junit.Test; +import static org.junit.Assert.*; + +public class CdbEntityControllerUtilityEventLifecycleTest { + + @Test + public void publishesGenericThenSpecializedEventAfterSuccessfulUpdate() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + TestEntity entity = new TestEntity(1); + utility.specializedUpdateEvents = Collections.singletonList(new TestEvent("specialized-update")); + + utility.update(entity, null); + + assertEquals(2, utility.publishedEvents.size()); + assertTrue(utility.publishedEvents.get(0) instanceof CdbEntityEvent); + assertEquals("specialized-update", utility.publishedEvents.get(1).getTopic()); + assertTrue(utility.facade.editCalled); + } + + @Test + public void publishesCollectedEventsForListOnlyAfterSuccessfulEdit() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + utility.createEventPerEntity = true; + + utility.updateList(Arrays.asList(new TestEntity(1), new TestEntity(2)), null); + + assertEquals(4, utility.publishedEvents.size()); + assertTrue(utility.publishedEvents.get(0) instanceof CdbEntityEvent); + assertTrue(utility.publishedEvents.get(1) instanceof CdbEntityEvent); + assertEquals("specialized-update/1", utility.publishedEvents.get(2).getTopic()); + assertEquals("specialized-update/2", utility.publishedEvents.get(3).getTopic()); + } + + @Test + public void doesNotPublishAnyEventWhenPersistenceFails() throws Exception { + TestFacade facade = new TestFacade(); + facade.failEdit = true; + TestUtility utility = new TestUtility(facade); + utility.specializedUpdateEvents = Collections.singletonList(new TestEvent("specialized-update")); + + try { + utility.update(new TestEntity(1), null); + fail("Expected persistence failure"); + } catch (IllegalStateException expected) { + assertEquals("edit failed", expected.getMessage()); + } + + assertTrue(utility.publishedEvents.isEmpty()); + } + + @Test + public void removalUpdatePublishesSpecializedEventOnlyAfterSuccessfulEdit() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + utility.specializedUpdateEvents = Collections.singletonList(new TestEvent("specialized-update/remove")); + + utility.updateOnRemoval(new TestEntity(1), null); + + assertEquals(2, utility.publishedEvents.size()); + assertTrue(utility.publishedEvents.get(0) instanceof CdbEntityEvent); + assertEquals("specialized-update/remove", utility.publishedEvents.get(1).getTopic()); + } + + @Test + public void updateWithoutSpecializedEventsStillPublishesGenericEvent() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + + utility.update(new TestEntity(1), null); + + assertEquals(1, utility.publishedEvents.size()); + assertTrue(utility.publishedEvents.get(0) instanceof CdbEntityEvent); + } + + @Test + public void createPublishesSpecializedCreateEventsAfterGenericAdd() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + utility.specializedCreateEvents = true; + + utility.create(new TestEntity(1), null); + + assertEquals(2, utility.publishedEvents.size()); + assertTrue(utility.publishedEvents.get(0) instanceof CdbEntityEvent); + assertEquals("specialized-create/1", utility.publishedEvents.get(1).getTopic()); + assertEquals(1, utility.preparedCount); + } + + @Test + public void createListPreparesOnceAndPublishesSpecializedCreateEventsLast() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + utility.specializedCreateEvents = true; + + utility.createList(Arrays.asList(new TestEntity(1), new TestEntity(2)), null); + + assertEquals(4, utility.publishedEvents.size()); + assertEquals("specialized-create/1", utility.publishedEvents.get(2).getTopic()); + assertEquals("specialized-create/2", utility.publishedEvents.get(3).getTopic()); + assertEquals(1, utility.preparedCount); + } + + @Test + public void updatePublishesSpecializedCreateEventsAfterSpecializedUpdateEvents() throws Exception { + TestUtility utility = new TestUtility(new TestFacade()); + utility.specializedCreateEvents = true; + utility.specializedUpdateEvents = Collections.singletonList(new TestEvent("specialized-update")); + + utility.update(new TestEntity(1), null); + + assertEquals(3, utility.publishedEvents.size()); + assertEquals("specialized-update", utility.publishedEvents.get(1).getTopic()); + assertEquals("specialized-create/1", utility.publishedEvents.get(2).getTopic()); + } + + @Test + public void failedCreateDoesNotPublishSpecializedCreateEvents() throws Exception { + TestFacade facade = new TestFacade(); + facade.failCreate = true; + TestUtility utility = new TestUtility(facade); + utility.specializedCreateEvents = true; + + try { + utility.create(new TestEntity(1), null); + fail("Expected persistence failure"); + } catch (IllegalStateException expected) { + assertEquals("create failed", expected.getMessage()); + } + + assertEquals(1, utility.preparedCount); + assertTrue(utility.publishedEvents.isEmpty()); + } + + private static final class TestEvent extends MqttEvent { + private final String topic; + + TestEvent(String topic) { + this.topic = topic; + } + + @Override + public String getTopic() { + return topic; + } + } + + private static final class TestEntity extends CdbEntity { + private final Integer id; + + TestEntity(Integer id) { + this.id = id; + } + + @Override + public Integer getId() { + return id; + } + } + + private static final class TestFacade extends CdbEntityFacade { + private boolean editCalled; + private boolean failEdit; + private boolean failCreate; + + TestFacade() { + super(TestEntity.class); + } + + @Override + protected EntityManager getEntityManager() { + throw new UnsupportedOperationException("Not used by this test"); + } + + @Override + public void create(TestEntity entity) { + if (failCreate) { + throw new IllegalStateException("create failed"); + } + } + + @Override + public TestEntity edit(TestEntity entity) { + editCalled = true; + if (failEdit) { + throw new IllegalStateException("edit failed"); + } + return entity; + } + + @Override + public void edit(List entities) { + editCalled = true; + if (failEdit) { + throw new IllegalStateException("edit failed"); + } + } + } + + private static final class TestUtility + extends CdbEntityControllerUtility { + private final TestFacade facade; + private final List publishedEvents = new ArrayList<>(); + private List specializedUpdateEvents = Collections.emptyList(); + private boolean createEventPerEntity; + private boolean specializedCreateEvents; + private int preparedCount; + + TestUtility(TestFacade facade) { + this.facade = facade; + } + + @Override + protected TestFacade getEntityDbFacade() { + return facade; + } + + @Override + public TestEntity createEntityInstance(UserInfo sessionUser) { + return new TestEntity(null); + } + + @Override + public String getEntityTypeName() { + return "testEntity"; + } + + @Override + protected List prepareSpecializedUpdateEvents( + TestEntity entity, UserInfo userInfo) { + if (createEventPerEntity) { + return Collections.singletonList(new TestEvent("specialized-update/" + entity.getId())); + } + return specializedUpdateEvents; + } + + @Override + protected void prepareSpecializedCreateEvents(List entities, UserInfo userInfo) { + preparedCount++; + } + + @Override + protected List createSpecializedCreateEvents( + List entities, UserInfo userInfo) { + List events = new ArrayList<>(); + if (specializedCreateEvents) { + for (TestEntity entity : entities) { + events.add(new TestEvent("specialized-create/" + entity.getId())); + } + } + return events; + } + + @Override + protected void addCreatedSystemLog(TestEntity entity, UserInfo createdByUserInfo) { + } + + @Override + protected void addCreatedWarningSystemLog(Exception exception, TestEntity entity, + UserInfo createdByUserInfo) { + } + + @Override + protected void publishMqttEvent(MqttEvent event) { + publishedEvents.add(event); + } + + @Override + protected void addCdbEntitySystemLog( + SystemLogLevel logLevel, String message, UserInfo sessionUser) throws CdbException { + } + + @Override + protected void addCdbEntityWarningSystemLog(String warningMessage, + Exception exception, CdbEntity entity, UserInfo sessionUser) throws CdbException { + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CombinedEventOrderingTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CombinedEventOrderingTest.java new file mode 100644 index 000000000..3ea9aa17a --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/CombinedEventOrderingTest.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogAttachmentEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineAddEvent; +import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElementRelationship; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.RelationshipType; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import gov.anl.aps.cdb.portal.model.db.utilities.LogUtility; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * One save mixing several changes publishes, after the generic entity event: specialized + * update events (log update/delete, attachments on existing logs), then specialized create + * events (machine adds, then each new log followed by its attachments), each exactly once. + */ +public class CombinedEventOrderingTest { + + @Test + public void logEditsAndAttachmentUploadsInOneSave() throws Exception { + Attachment existingLogUpload = attachment("attachment.e.pdf", "e.pdf"); + Attachment newLogUpload = attachment("attachment.n.pdf", "n.pdf"); + ItemDomainCatalog original = catalog(log(10, "edit me"), log(11, "attach here"), log(12, "delete me")); + ItemDomainCatalog edited = catalog(log(null, "brand new", newLogUpload), + log(10, "edited"), log(11, "attach here", existingLogUpload)); + CatalogUtility utility = new CatalogUtility(original); + + List events = utility.save(edited, + catalog(log(70, "brand new"), log(10, "edited"), log(11, "attach here"))); + + assertTopics(events, + "cdb/item/log/update", + "cdb/item/log/delete", + "cdb/item/log/attachment/add", + "cdb/item/log/add", + "cdb/item/log/attachment/add"); + assertEquals("11", ((CdbItemLogAttachmentEvent) events.get(2)).getLogId()); + assertEquals("70", ((CdbItemLogEvent) events.get(3)).getNewLog().getLogId()); + assertEquals("70", ((CdbItemLogAttachmentEvent) events.get(4)).getLogId()); + assertNoDuplicates(events); + + // The save assigned attachment IDs; saving again with nothing new publishes nothing more. + existingLogUpload.setId(500); + newLogUpload.setId(501); + utility.current = catalog(log(70, "brand new", newLogUpload), log(10, "edited"), + log(11, "attach here", existingLogUpload)); + assertTrue(utility.save(catalog(log(70, "brand new", newLogUpload), log(10, "edited"), + log(11, "attach here", existingLogUpload)), null).isEmpty()); + } + + @Test + public void templateInstantiationWithParentLogInOneSave() { + ItemDomainMachineDesign template = machine(800, "Template"); + ItemDomainMachineDesign templateChild = machine(801, "Template child"); + ItemDomainMachineDesign parent = machine(1, "Parent"); + parent.getSelfElement().setLogList(new ArrayList<>(Collections.singletonList(log(null, "parent note")))); + ItemDomainMachineDesign top = fromTemplate(machine(null, "Instance"), template); + ItemDomainMachineDesign child = fromTemplate(machine(null, "Instance child"), templateChild); + child(parent, top); + child(top, child); + MachineUtility utility = new MachineUtility(); + utility.persistedIds.addAll(Arrays.asList(1, 800, 801)); + List entities = Collections.singletonList(parent); + + utility.prepareSpecializedCreateEvents(entities, user()); + top.setId(100); + child.setId(101); + ItemDomainMachineDesign persistedParent = machine(1, "Parent"); + persistedParent.getSelfElement().setLogList(new ArrayList<>( + Collections.singletonList(log(90, "parent note")))); + utility.persistedParent = persistedParent; + List events = utility.createSpecializedCreateEvents(entities, user()); + + assertTopics(events, "cdb/machine-design/add", "cdb/machine-design/add", "cdb/item/log/add"); + CdbMachineAddEvent first = (CdbMachineAddEvent) events.get(0); + CdbMachineAddEvent second = (CdbMachineAddEvent) events.get(1); + assertEquals("100", first.getMachine().getId()); + assertEquals("101", second.getMachine().getId()); + assertEquals(Integer.valueOf(2), second.getTemplateCreatedMachineCount()); + assertEquals("90", ((CdbItemLogEvent) events.get(2)).getNewLog().getLogId()); + assertNoDuplicates(events); + } + + private static void assertTopics(List events, String... topics) { + List actual = new ArrayList<>(); + for (MqttEvent event : events) { + actual.add(event.getTopic()); + } + assertEquals(Arrays.asList(topics), actual); + } + + // Duplicate = same topic and same subject (machine, log, or log + attachment). + private static void assertNoDuplicates(List events) { + Set keys = new HashSet<>(); + for (MqttEvent event : events) { + String subject; + if (event instanceof CdbMachineAddEvent) { + subject = ((CdbMachineAddEvent) event).getMachine().getId(); + } else if (event instanceof CdbItemLogAttachmentEvent) { + CdbItemLogAttachmentEvent attachment = (CdbItemLogAttachmentEvent) event; + subject = attachment.getLogId() + "/" + attachment.getAttachment().getName(); + } else { + CdbItemLogEvent log = (CdbItemLogEvent) event; + subject = (log.getNewLog() != null ? log.getNewLog() : log.getOldLog()).getLogId(); + } + assertTrue("Duplicate " + event.getTopic() + " for " + subject, + keys.add(event.getTopic() + ":" + subject)); + } + } + + private static Attachment attachment(String name, String originalFilename) { + Attachment attachment = new Attachment(null, name); + attachment.setOriginalFilename(originalFilename); + return attachment; + } + + private static Log log(Integer id, String text, Attachment... attachments) { + Log log = new Log(); + log.setId(id); + log.setText(text); + log.setEnteredByUser(user()); + log.setEnteredOnDateTime(new Date(1000)); + log.setAttachmentList(new ArrayList<>(Arrays.asList(attachments))); + return log; + } + + private static ItemDomainCatalog catalog(Log... logs) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + item.setName("Item"); + selfElement(item).setLogList(new ArrayList<>(Arrays.asList(logs))); + return item; + } + + private static ItemDomainMachineDesign machine(Integer id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + item.setItemElementMemberList(new ArrayList<>()); + selfElement(item); + item.setEntityInfo(new EntityInfo()); + return item; + } + + private static ItemElement selfElement(Item item) { + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return self; + } + + private static void child(ItemDomainMachineDesign parent, ItemDomainMachineDesign contained) { + ItemElement element = new ItemElement(); + element.setParentItem(parent); + element.setContainedItem(contained); + element.setName("E" + parent.getFullItemElementList().size()); + parent.getFullItemElementList().add(element); + contained.getItemElementMemberList().add(element); + parent.resetItemElementVars(); + } + + private static ItemDomainMachineDesign fromTemplate(ItemDomainMachineDesign machine, + ItemDomainMachineDesign template) { + ItemElementRelationship relationship = new ItemElementRelationship(); + relationship.setRelationshipType(new RelationshipType(1, + ItemElementRelationshipTypeNames.template.getValue())); + relationship.setFirstItemElement(machine.getSelfElement()); + relationship.setSecondItemElement(template.getSelfElement()); + machine.setItemElementRelationshipList(new ArrayList<>(Collections.singletonList(relationship))); + return machine; + } + + private static UserInfo user() { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + return user; + } + + private static final class CatalogUtility extends ItemDomainCatalogControllerUtility { + private ItemDomainCatalog current; + + CatalogUtility(ItemDomainCatalog original) { + current = original; + } + + @Override + public ItemDomainCatalog findById(Integer id) { + return current; + } + + // Mirrors CdbEntityControllerUtility.update ordering around a successful edit. + List save(ItemDomainCatalog edited, ItemDomainCatalog afterSave) throws Exception { + edited.setPendingLogChanges(LogUtility.prepareLogChanges( + current.getLogList(), edited.getLogList())); + edited.setPendingLogAttachmentChanges(LogUtility.findAddedAttachments( + current.getLogList(), edited.getLogList())); + List events = new ArrayList<>(prepareSpecializedUpdateEvents(edited, user())); + List entities = Collections.singletonList(edited); + prepareSpecializedCreateEvents(entities, user()); + if (afterSave != null) { + current = afterSave; + } + events.addAll(createSpecializedCreateEvents(entities, user())); + return events; + } + } + + private static final class MachineUtility extends ItemDomainMachineDesignControllerUtility { + private final Set persistedIds = new HashSet<>(); + private ItemDomainMachineDesign persistedParent; + + @Override + protected boolean isMachinePersisted(ItemDomainMachineDesign machine) { + return machine.getId() != null && persistedIds.contains(machine.getId()); + } + + @Override + public ItemDomainMachineDesign findById(Integer id) { + return persistedParent; + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/InventoryAssemblyElementEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/InventoryAssemblyElementEventDetectionTest.java new file mode 100644 index 000000000..7b97c652e --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/InventoryAssemblyElementEventDetectionTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbInventoryAssemblyElementEvent; +import gov.anl.aps.cdb.mqtt.metadata.InventoryAssemblyElementChange; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventory; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class InventoryAssemblyElementEventDetectionTest { + + @Test + public void convertsAssignReplaceAndClearMetadataToEvents() throws Exception { + ItemDomainInventory assembly = inventory(1, "Assembly"); + ItemElement element = element(10, assembly); + ItemDomainInventory first = inventory(2, "First"); + ItemDomainInventory second = inventory(3, "Second"); + assembly.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange(element, null, first)); + assembly.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange(element, first, second)); + assembly.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange(element, second, null)); + + List events = new TestUtility().events(assembly); + + assertEquals(3, events.size()); + CdbInventoryAssemblyElementEvent assign + = (CdbInventoryAssemblyElementEvent) events.get(0); + assertNull(assign.getOldAssignedInventoryItem()); + assertEquals("2", assign.getNewAssignedInventoryItem().getId()); + CdbInventoryAssemblyElementEvent clear + = (CdbInventoryAssemblyElementEvent) events.get(2); + assertEquals("3", clear.getOldAssignedInventoryItem().getId()); + assertNull(clear.getNewAssignedInventoryItem()); + } + + @Test + public void persistedElementComparisonDetectsAssignmentAndSuppressesNoOp() throws Exception { + ItemDomainInventory original = inventory(1, "Assembly"); + ItemElement originalElement = element(10, original); + original.getFullItemElementList().add(originalElement); + ItemDomainInventory edited = inventory(1, "Assembly"); + ItemElement editedElement = element(10, edited); + ItemDomainInventory assigned = inventory(2, "Assigned"); + editedElement.setContainedItem(assigned); + edited.getFullItemElementList().add(editedElement); + TestUtility utility = new TestUtility(); + + utility.detect(original, edited); + + List events = utility.events(edited); + assertEquals(1, events.size()); + CdbInventoryAssemblyElementEvent event + = (CdbInventoryAssemblyElementEvent) events.get(0); + assertNull(event.getOldAssignedInventoryItem()); + assertEquals("2", event.getNewAssignedInventoryItem().getId()); + } + + @Test + public void includesChangesFromNewlyGeneratedChildAssembly() throws Exception { + ItemDomainInventory root = inventory(1, "Root"); + ItemDomainInventory childAssembly = inventory(2, "Child Assembly"); + ItemDomainInventory grandchild = inventory(3, "Grandchild"); + ItemElement rootElement = element(10, root); + ItemElement childElement = element(11, childAssembly); + root.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange(rootElement, null, childAssembly)); + childAssembly.addPendingAssemblyElementChange( + new InventoryAssemblyElementChange(childElement, null, grandchild)); + + List events = new TestUtility().events(root); + + assertEquals(2, events.size()); + assertEquals("1", ((CdbInventoryAssemblyElementEvent) events.get(0)) + .getInventoryAssembly().getId()); + assertEquals("2", ((CdbInventoryAssemblyElementEvent) events.get(1)) + .getInventoryAssembly().getId()); + } + + @Test + public void unchangedAssemblyProducesNoEvent() throws Exception { + assertTrue(new TestUtility().events(inventory(1, "Assembly")).isEmpty()); + } + + private static ItemElement element(int id, Item parent) { + ItemElement element = new ItemElement(); + element.setId(id); + element.setName("E" + id); + element.setParentItem(parent); + return element; + } + + private static ItemDomainInventory inventory(int id, String name) { + ItemDomainInventory item = new ItemDomainInventory(); + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static final class TestUtility extends ItemDomainInventoryControllerUtility { + void detect(ItemDomainInventory original, ItemDomainInventory edited) { + prepareInventoryAssemblyElementChanges(original, edited, + original.getFullItemElementList(), edited.getFullItemElementList()); + } + + List events(ItemDomainInventory item) throws Exception { + return prepareSpecializedUpdateEvents(item, null); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignControllerUtilityTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignControllerUtilityTest.java index 66865f120..2a050b12e 100644 --- a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignControllerUtilityTest.java +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemDomainMachineDesignControllerUtilityTest.java @@ -4,9 +4,11 @@ */ package gov.anl.aps.cdb.portal.controllers.utilities; +import gov.anl.aps.cdb.base.mqtt.MqttEvent; import gov.anl.aps.cdb.common.exceptions.CdbException; import gov.anl.aps.cdb.common.exceptions.InvalidArgument; import gov.anl.aps.cdb.common.exceptions.InvalidObjectState; +import gov.anl.aps.cdb.mqtt.CdbMachineMoveEvent; import gov.anl.aps.cdb.portal.model.db.beans.ItemDomainMachineDesignFacade; import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; import gov.anl.aps.cdb.portal.model.db.entities.EntityType; @@ -51,6 +53,10 @@ public void movesExistingChildToNewParentAndUpdatesParent() throws Exception { assertEquals("E1", membership.getName()); assertTrue(newParent.getFullItemElementList().contains(membership)); assertEquals(1, utility.updateCount); + assertEquals(1, utility.publishedEvents.size()); + CdbMachineMoveEvent event = (CdbMachineMoveEvent) utility.publishedEvents.get(0); + assertEquals(oldParent.getId(), integerId(event.getOldParent().getId())); + assertEquals(newParent.getId(), integerId(event.getNewParent().getId())); } @Test @@ -65,6 +71,36 @@ public void movesTopLevelMachineByCreatingMembership() throws Exception { assertEquals("E1", result.getName()); assertTrue(newParent.getFullItemElementList().contains(result)); assertEquals(1, utility.updateCount); + CdbMachineMoveEvent event = (CdbMachineMoveEvent) utility.publishedEvents.get(0); + assertNull(event.getOldParent()); + assertEquals(newParent.getId(), integerId(event.getNewParent().getId())); + } + + @Test + public void suppressesNoOpMove() throws Exception { + ItemDomainMachineDesign parent = machine("Parent"); + ItemDomainMachineDesign moved = machine("Moved"); + ItemElement membership = child(parent, moved, "E1"); + + ItemElement result = utility.performMachineMoveWithUpdate(parent, moved, user); + + assertSame(membership, result); + assertEquals(0, utility.updateCount); + assertTrue(utility.publishedEvents.isEmpty()); + } + + @Test + public void doesNotPublishMoveWhenUpdateFails() { + ItemDomainMachineDesign oldParent = machine("Old"); + ItemDomainMachineDesign newParent = machine("New"); + ItemDomainMachineDesign moved = machine("Moved"); + child(oldParent, moved, "E1"); + utility.failUpdate = true; + + expect(CdbException.class, + () -> utility.performMachineMoveWithUpdate(newParent, moved, user)); + + assertTrue(utility.publishedEvents.isEmpty()); } @Test @@ -297,6 +333,36 @@ public void allowsNestedRepresentationBelowRepresentedParent() throws Exception assertSame(nestedPartElement, representedChild.getRepresentsCatalogElement()); } + @Test + public void propagatesRepresentedElementChangeToTemplateInstance() throws Exception { + ItemDomainCatalog templateAssembly = catalog("Template Assembly"); + ItemElement templateElement = child(templateAssembly, catalog("Part"), "E1"); + ItemDomainMachineDesign templateParent = machine("Template Parent"); + templateParent.setAssignedItem(templateAssembly); + TestMachine templateNode = testMachine("Template Node"); + templateNode.setEntityTypeListFromApi( + Collections.singletonList(entityType(1, "Template"))); + child(templateParent, templateNode, "T1"); + + ItemDomainCatalog instanceAssembly = catalog("Instance Assembly"); + ItemElement instanceElement = child(instanceAssembly, catalog("Part"), "E1"); + ItemDomainMachineDesign instanceParent = machine("Instance Parent"); + instanceParent.setAssignedItem(instanceAssembly); + TestMachine instanceNode = testMachine("Instance Node"); + instanceNode.assignCreatedFromTemplate(templateNode); + child(instanceParent, instanceNode, "I1"); + templateNode.addTemplateInstance(instanceNode); + + utility.updateRepresentingAssemblyElementForMachine( + templateNode, templateElement, true); + + assertSame(templateElement, templateNode.getRepresentsCatalogElement()); + assertSame(instanceElement, instanceNode.getRepresentsCatalogElement()); + assertSame(templateNode, + instanceNode.getPendingRepresentedElementChange().getSourceTemplate()); + assertTrue(templateNode.getItemsToUpdate().contains(instanceNode)); + } + @Test public void availableElementsExcludeElementsRepresentedBySiblings() { RepresentationFixture fixture = representationFixture(); @@ -373,6 +439,10 @@ public void searchDelegatesToNoEntityTypeFacade() { assertEquals("sector*", facade.searchString); } + private static Integer integerId(String id) { + return id == null ? null : Integer.valueOf(id); + } + private static UserInfo user(int id) { UserInfo value = new UserInfo(); value.setId(id); @@ -388,8 +458,11 @@ private static EntityType entityType(int id, String name) { return new EntityType(id, name); } + private static int nextMachineId = 1; + private static ItemDomainMachineDesign machine(String name) { ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(nextMachineId++); item.setName(name); item.setEntityTypeListFromApi(new ArrayList<>()); item.setItemProjectList(new ArrayList<>()); @@ -456,9 +529,20 @@ private interface ThrowingAction { private static class TestMachine extends ItemDomainMachineDesign { + private final List templateInstances = new ArrayList<>(); + void assignCreatedFromTemplate(Item template) { createdFromTemplate = template; } + + void addTemplateInstance(Item item) { + templateInstances.add(item); + } + + @Override + public List getItemsCreatedFromThisTemplateItem() { + return templateInstances; + } } private static TestMachine testMachine(String name) { @@ -493,6 +577,8 @@ public List searchEntitiesNoEntityType(String value) { private static class TestUtility extends ItemDomainMachineDesignControllerUtility { private int updateCount; + private boolean failUpdate; + private final List publishedEvents = new ArrayList<>(); void useFacade(ItemDomainMachineDesignFacade facade) { itemFacade = facade; @@ -508,9 +594,17 @@ public ItemDomainMachineDesign createEntityInstanceBasedOnParent( @Override public ItemDomainMachineDesign update(ItemDomainMachineDesign entity, - UserInfo updatedByUserInfo) { + UserInfo updatedByUserInfo) throws CdbException { updateCount++; + if (failUpdate) { + throw new CdbException("update failed"); + } return entity; } + + @Override + protected void publishMqttEvent(MqttEvent event) { + publishedEvents.add(event); + } } } diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLocationEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLocationEventDetectionTest.java new file mode 100644 index 000000000..884413b4c --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLocationEventDetectionTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLocationEvent; +import gov.anl.aps.cdb.mqtt.metadata.ItemLocationChange; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainInventory; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainLocation; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ItemLocationEventDetectionTest { + + @Test + public void convertsPendingLocationChangeToEvent() throws Exception { + ItemDomainInventory affected = inventory(1, "Device"); + ItemDomainLocation oldLocation = location(2, "Old"); + ItemDomainLocation newLocation = location(3, "New"); + affected.setPendingLocationChange(new ItemLocationChange( + oldLocation, "old details", newLocation, "new details")); + + List events = new TestUtility().events(affected); + + assertEquals(1, events.size()); + CdbItemLocationEvent event = (CdbItemLocationEvent) events.get(0); + assertEquals("2", event.getOldLocation().getId()); + assertEquals("3", event.getNewLocation().getId()); + } + + @Test + public void itemWithoutPendingChangeProducesNoLocationEvent() throws Exception { + assertTrue(new TestUtility().events(inventory(1, "Device")).isEmpty()); + } + + private static ItemDomainInventory inventory(int id, String name) { + ItemDomainInventory item = new ItemDomainInventory(); + initialize(item, id, name); + return item; + } + + private static ItemDomainLocation location(int id, String name) { + ItemDomainLocation item = new ItemDomainLocation(); + initialize(item, id, name); + return item; + } + + private static void initialize(Item item, int id, String name) { + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } + + private static final class TestUtility extends ItemDomainInventoryControllerUtility { + List events(ItemDomainInventory item) throws Exception { + return prepareSpecializedUpdateEvents(item, null); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentEventDetectionTest.java new file mode 100644 index 000000000..3aa374849 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentEventDetectionTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogAttachmentEvent; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import gov.anl.aps.cdb.portal.model.db.utilities.LogUtility; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ItemLogAttachmentEventDetectionTest { + + @Test + public void unsavedAttachmentOnExistingLogIsAnUpdateEventBuiltBeforeSave() throws Exception { + ItemDomainCatalog original = item(log(10, "entry")); + ItemDomainCatalog edited = item(log(10, "entry", attachment(null, "attachment.b.png", "b.png"))); + + List events = new TestUtility(original).updateEvents(edited); + + assertEquals(1, events.size()); + assertAttachment(events.get(0), "10", "attachment.b.png", "b.png"); + } + + @Test + public void preSavedRestAttachmentIsDetectedByUnreferencedId() throws Exception { + ItemDomainCatalog original = item(log(10, "entry", attachment(40, "attachment.old.pdf", "old.pdf"))); + ItemDomainCatalog edited = item(log(10, "entry", + attachment(40, "attachment.old.pdf", "old.pdf"), + attachment(50, "attachment.a.pdf", "a.pdf"))); + + List events = new TestUtility(original).updateEvents(edited); + + assertEquals(1, events.size()); + assertAttachment(events.get(0), "10", "attachment.a.pdf", "a.pdf"); + } + + @Test + public void unchangedAttachmentsProduceNoEvents() throws Exception { + ItemDomainCatalog original = item(log(10, "entry", attachment(40, "attachment.old.pdf", "old.pdf"))); + ItemDomainCatalog edited = item(log(10, "entry", attachment(40, "attachment.old.pdf", "old.pdf"))); + + assertTrue(new TestUtility(original).updateEvents(edited).isEmpty()); + } + + @Test + public void attachmentOnlyEditIsNotALogUpdate() throws Exception { + ItemDomainCatalog original = item(log(10, "entry")); + ItemDomainCatalog edited = item(log(10, "entry", attachment(null, "attachment.a.pdf", "a.pdf"))); + + List events = new TestUtility(original).updateEvents(edited); + + assertEquals(1, events.size()); + assertTrue(events.get(0) instanceof CdbItemLogAttachmentEvent); + } + + @Test + public void sameAttachmentListedTwiceIsPublishedOnce() throws Exception { + Attachment attachment = attachment(null, "attachment.d.pdf", "d.pdf"); + ItemDomainCatalog original = item(log(10, "entry")); + ItemDomainCatalog edited = item(log(10, "entry", attachment, attachment)); + + assertEquals(1, new TestUtility(original).updateEvents(edited).size()); + } + + @Test + public void newLogAttachmentsFollowTheLogAddWithItsResolvedId() { + Attachment first = attachment(null, "attachment.1.txt", "one.txt"); + Attachment second = attachment(null, "attachment.2.txt", "two.txt"); + ItemDomainCatalog edited = item(log(null, "new entry", first, second, first), log(10, "entry")); + TestUtility utility = new TestUtility(null); + List entities = Collections.singletonList(edited); + + utility.prepareSpecializedCreateEvents(entities, user()); + utility.original = item(log(70, "new entry"), log(10, "entry")); + List events = utility.createSpecializedCreateEvents(entities, user()); + + assertEquals(3, events.size()); + assertEquals("cdb/item/log/add", events.get(0).getTopic()); + assertAttachment(events.get(1), "70", "attachment.1.txt", "one.txt"); + assertAttachment(events.get(2), "70", "attachment.2.txt", "two.txt"); + } + + @Test + public void unpersistedNewLogPublishesNoAttachmentEvents() { + ItemDomainCatalog edited = item(log(null, "never saved", attachment(null, "attachment.x.pdf", "x.pdf"))); + TestUtility utility = new TestUtility(null); + List entities = Collections.singletonList(edited); + + utility.prepareSpecializedCreateEvents(entities, user()); + utility.original = item(); + + assertTrue(utility.createSpecializedCreateEvents(entities, user()).isEmpty()); + } + + private static void assertAttachment(MqttEvent value, String logId, String name, + String originalFilename) { + CdbItemLogAttachmentEvent event = (CdbItemLogAttachmentEvent) value; + assertEquals("cdb/item/log/attachment/add", event.getTopic()); + assertEquals(logId, event.getLogId()); + assertEquals(name, event.getAttachment().getName()); + assertEquals(originalFilename, event.getAttachment().getOriginalFilename()); + assertEquals("operator", event.getTriggeredByUsername()); + } + + private static Attachment attachment(Integer id, String name, String originalFilename) { + Attachment attachment = new Attachment(id, name); + attachment.setOriginalFilename(originalFilename); + return attachment; + } + + private static Log log(Integer id, String text, Attachment... attachments) { + Log log = new Log(); + log.setId(id); + log.setText(text); + log.setEnteredByUser(user()); + log.setEnteredOnDateTime(new Date(1000)); + log.setAttachmentList(new ArrayList<>(Arrays.asList(attachments))); + return log; + } + + private static ItemDomainCatalog item(Log... logs) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + item.setName("Item"); + ItemElement self = new ItemElement(); + self.setParentItem(item); + self.setLogList(new ArrayList<>(Arrays.asList(logs))); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static UserInfo user() { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + return user; + } + + private static final class TestUtility extends ItemDomainCatalogControllerUtility { + private ItemDomainCatalog original; + + TestUtility(ItemDomainCatalog original) { + this.original = original; + } + + @Override + public ItemDomainCatalog findById(Integer id) { + return original; + } + + // Mirrors update(): diffs from prepareEntityUpdate, then specialized update events. + List updateEvents(ItemDomainCatalog edited) throws Exception { + edited.setPendingLogChanges(LogUtility.prepareLogChanges( + original.getLogList(), edited.getLogList())); + edited.setPendingLogAttachmentChanges(LogUtility.findAddedAttachments( + original.getLogList(), edited.getLogList())); + return prepareSpecializedUpdateEvents(edited, user()); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentUtilityTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentUtilityTest.java new file mode 100644 index 000000000..f5eb25d80 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogAttachmentUtilityTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.common.exceptions.CdbException; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ItemLogAttachmentUtilityTest { + + @Test + public void appendsAttachmentAndSavesThroughItemUpdate() throws Exception { + Log log = log(10); + ItemDomainCatalog item = item(log); + Attachment attachment = new Attachment(5); + TestUtility utility = new TestUtility(); + + assertSame(item, utility.addLogAttachment(item, log, attachment, new UserInfo())); + + assertEquals(Collections.singletonList(attachment), log.getAttachmentList()); + assertSame(item, utility.updated); + } + + @Test + public void rejectsLogFromAnotherItemWithoutSaving() { + TestUtility utility = new TestUtility(); + Log foreign = log(99); + + try { + utility.addLogAttachment(item(log(10)), foreign, new Attachment(5), new UserInfo()); + fail("Expected CdbException"); + } catch (CdbException expected) { + assertNull(utility.updated); + assertNull(foreign.getAttachmentList()); + } + } + + private static Log log(int id) { + Log log = new Log(); + log.setId(id); + return log; + } + + private static ItemDomainCatalog item(Log... logs) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + ItemElement self = new ItemElement(); + self.setParentItem(item); + self.setLogList(new ArrayList<>(Arrays.asList(logs))); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static final class TestUtility extends ItemDomainCatalogControllerUtility { + private ItemDomainCatalog updated; + + @Override + public ItemDomainCatalog update(ItemDomainCatalog item, UserInfo user) { + updated = item; + return item; + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogEventDetectionTest.java new file mode 100644 index 000000000..2d01a83bb --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemLogEventDetectionTest.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbItemLogEvent; +import gov.anl.aps.cdb.portal.model.db.entities.Attachment; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.Log; +import gov.anl.aps.cdb.portal.model.db.entities.LogTopic; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import gov.anl.aps.cdb.portal.model.db.utilities.LogUtility; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ItemLogEventDetectionTest { + + @Test + public void classifiesUpdateAndDeleteByStableId() throws Exception { + ItemDomainCatalog original = item(log(10, "unchanged"), log(11, "old"), log(12, "gone")); + ItemDomainCatalog edited = item(log(10, "unchanged"), log(11, "new")); + + List events = new TestUtility(original).detect(edited); + + assertEquals(2, events.size()); + CdbItemLogEvent update = (CdbItemLogEvent) events.get(0); + assertEquals("cdb/item/log/update", update.getTopic()); + assertEquals("11", update.getNewLog().getLogId()); + assertEquals("-old\n+new\n", update.getTextDiff()); + CdbItemLogEvent delete = (CdbItemLogEvent) events.get(1); + assertEquals("cdb/item/log/delete", delete.getTopic()); + assertEquals("12", delete.getOldLog().getLogId()); + assertEquals("gone", delete.getOldLog().getText()); + } + + @Test + public void unchangedAttachmentOnlyAndSameInstantEditsProduceNoEvents() throws Exception { + Log originalLog = log(10, "text"); + originalLog.setEffectiveFromDateTime(new Timestamp(1000)); + Log editedLog = log(10, "text"); + editedLog.setAttachmentList(new ArrayList<>(Collections.singletonList(new Attachment(5)))); + + assertTrue(new TestUtility(item(originalLog)).detect(item(editedLog)).isEmpty()); + } + + @Test + public void effectiveDateAndTopicChangesAreUpdates() throws Exception { + Log dateEdit = log(10, "text"); + dateEdit.setEffectiveFromDateTime(new Date(9000)); + Log topicEdit = log(11, "text"); + topicEdit.setLogTopic(new LogTopic(2, "Repair")); + ItemDomainCatalog original = item(log(10, "text"), log(11, "text")); + + List events = new TestUtility(original).detect(item(dateEdit, topicEdit)); + + assertEquals(2, events.size()); + assertNull(((CdbItemLogEvent) events.get(0)).getTextDiff()); + assertEquals("Repair", ((CdbItemLogEvent) events.get(1)).getNewLog().getLogTopicName()); + } + + @Test + public void newLogsAreNotUpdateEvents() throws Exception { + ItemDomainCatalog original = item(log(10, "existing")); + ItemDomainCatalog edited = item(log(null, "new"), log(10, "existing")); + + assertTrue(new TestUtility(original).detect(edited).isEmpty()); + } + + @Test + public void removalLifecycleProducesDeleteEvent() throws Exception { + TestUtility utility = new TestUtility(item(log(10, "removed"))); + ItemDomainCatalog edited = item(); + + utility.detectRemoval(edited); + List events = utility.detectPending(edited); + + assertEquals(1, events.size()); + assertEquals("cdb/item/log/delete", events.get(0).getTopic()); + } + + @Test + public void createPathUsesIdsAssignedInPlace() { + Log added = log(null, "on create"); + ItemDomainCatalog created = item(added); + TestUtility utility = new TestUtility(null); + + utility.prepareSpecializedCreateEvents(Collections.singletonList(created), user()); + added.setId(70); + List events = utility.createSpecializedCreateEvents( + Collections.singletonList(created), user()); + + assertEquals(1, events.size()); + CdbItemLogEvent event = (CdbItemLogEvent) events.get(0); + assertEquals("cdb/item/log/add", event.getTopic()); + assertEquals("70", event.getNewLog().getLogId()); + assertEquals("on create", event.getNewLog().getText()); + } + + @Test + public void updatePathResolvesMergedIdsFromPersistedItem() { + ItemDomainCatalog edited = item(log(null, "second"), log(null, "first"), log(10, "existing")); + TestUtility utility = new TestUtility(null); + utility.prepareSpecializedCreateEvents(Collections.singletonList(edited), user()); + // Merge assigns IDs only to managed copies; the utility reads them back. + utility.original = item(log(71, "first"), log(72, "second"), log(10, "existing")); + + List events = utility.createSpecializedCreateEvents( + Collections.singletonList(edited), user()); + + assertEquals(2, events.size()); + assertEquals("72", ((CdbItemLogEvent) events.get(0)).getNewLog().getLogId()); + assertEquals("71", ((CdbItemLogEvent) events.get(1)).getNewLog().getLogId()); + assertTrue(edited.getPendingAddedLogs().isEmpty()); + } + + @Test + public void unpersistedNewLogPublishesNothing() { + ItemDomainCatalog edited = item(log(null, "never saved"), log(10, "existing")); + TestUtility utility = new TestUtility(null); + utility.prepareSpecializedCreateEvents(Collections.singletonList(edited), user()); + utility.original = item(log(10, "existing")); + + assertTrue(utility.createSpecializedCreateEvents( + Collections.singletonList(edited), user()).isEmpty()); + } + + private static Log log(Integer id, String text) { + Log log = new Log(); + log.setId(id); + log.setText(text); + log.setEnteredByUser(user()); + log.setEnteredOnDateTime(new Date(1000)); + log.setEffectiveFromDateTime(new Date(1000)); + return log; + } + + private static ItemDomainCatalog item(Log... logs) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(1); + item.setName("Item"); + ItemElement self = new ItemElement(); + self.setParentItem(item); + self.setPropertyValueList(new ArrayList<>()); + self.setLogList(new ArrayList<>(Arrays.asList(logs))); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static UserInfo user() { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + return user; + } + + private static final class TestUtility extends ItemDomainCatalogControllerUtility { + private ItemDomainCatalog original; + + TestUtility(ItemDomainCatalog original) { + this.original = original; + } + + @Override + public ItemDomainCatalog findById(Integer id) { + return original; + } + + void detectRemoval(ItemDomainCatalog edited) throws Exception { + prepareEntityUpdateOnRemoval(edited); + } + + List detectPending(ItemDomainCatalog edited) throws Exception { + return prepareSpecializedUpdateEvents(edited, user()); + } + + List detect(ItemDomainCatalog edited) throws Exception { + edited.setPendingLogChanges(LogUtility.prepareLogChanges( + original.getLogList(), edited.getLogList())); + return prepareSpecializedUpdateEvents(edited, user()); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemPropertyEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemPropertyEventDetectionTest.java new file mode 100644 index 000000000..b4f33b056 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/ItemPropertyEventDetectionTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbItemPropertyEvent; +import gov.anl.aps.cdb.portal.model.db.beans.ItemDomainCatalogFacade; +import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyType; +import gov.anl.aps.cdb.portal.model.db.entities.PropertyValue; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class ItemPropertyEventDetectionTest { + + @Test + public void classifiesAddUpdateAndDeleteByStableId() throws Exception { + ItemDomainCatalog original = item(1, + property(10, "unchanged", "a"), + property(11, "updated", "old"), + property(12, "deleted", "gone")); + ItemDomainCatalog edited = item(1, + property(10, "unchanged", "a"), + property(11, "updated", "new"), + property(null, "added", "here")); + TestUtility utility = new TestUtility(original); + + List events = utility.detect(edited); + + assertEquals(3, events.size()); + assertEquals("cdb/item/property/update", events.get(0).getTopic()); + assertEquals("cdb/item/property/add", events.get(1).getTopic()); + assertEquals("cdb/item/property/delete", events.get(2).getTopic()); + CdbItemPropertyEvent update = (CdbItemPropertyEvent) events.get(0); + assertEquals("old", update.getOldProperty().getValue()); + assertEquals("new", update.getNewProperty().getValue()); + } + + @Test + public void identicalPropertiesProduceNoEvents() throws Exception { + ItemDomainCatalog original = item(1, property(10, "status", "Installed")); + ItemDomainCatalog edited = item(1, property(10, "status", "Installed")); + + assertTrue(new TestUtility(original).detect(edited).isEmpty()); + } + + @Test + public void removalLifecycleProducesDeleteEvent() throws Exception { + ItemDomainCatalog original = item(1, property(10, "removed", "old")); + ItemDomainCatalog edited = item(1); + TestUtility utility = new TestUtility(original); + + utility.detectRemoval(edited); + List events = utility.detectPending(edited); + + assertEquals(1, events.size()); + CdbItemPropertyEvent event = (CdbItemPropertyEvent) events.get(0); + assertEquals("cdb/item/property/delete", event.getTopic()); + assertEquals("old", event.getOldProperty().getValue()); + assertNull(event.getNewProperty()); + } + + @Test + public void statusPropertyUsesOrdinaryPropertyUpdateEvent() throws Exception { + ItemDomainCatalog original = item(1, property(10, "inventory_status", "Available")); + ItemDomainCatalog edited = item(1, property(10, "inventory_status", "Installed")); + + CdbItemPropertyEvent event = (CdbItemPropertyEvent) + new TestUtility(original).detect(edited).get(0); + + assertEquals("inventory_status", event.getNewProperty().getPropertyTypeName()); + assertEquals("Available", event.getOldProperty().getValue()); + assertEquals("Installed", event.getNewProperty().getValue()); + } + + private static PropertyValue property(Integer id, String typeName, String valueText) { + PropertyValue value = new PropertyValue(); + value.setId(id); + PropertyType propertyType = new PropertyType(100 + (id == null ? 0 : id), typeName); + propertyType.setPropertyTypeMetadataList(new ArrayList<>()); + value.setPropertyType(propertyType); + value.setValue(valueText); + value.setPropertyMetadataList(new ArrayList<>()); + return value; + } + + private static ItemDomainCatalog item(int id, PropertyValue... values) { + ItemDomainCatalog item = new ItemDomainCatalog(); + item.setId(id); + item.setName("Item"); + ItemElement self = new ItemElement(); + self.setParentItem(item); + self.setPropertyValueList(new ArrayList<>(Arrays.asList(values))); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static final class TestUtility extends ItemDomainCatalogControllerUtility { + private final ItemDomainCatalog original; + + TestUtility(ItemDomainCatalog original) { + this.original = original; + } + + @Override + public ItemDomainCatalog findById(Integer id) { + return original; + } + + void detectRemoval(ItemDomainCatalog edited) throws Exception { + prepareEntityUpdateOnRemoval(edited); + } + + List detectPending(ItemDomainCatalog edited) throws Exception { + return prepareSpecializedUpdateEvents(edited, new UserInfo()); + } + + List detect(ItemDomainCatalog edited) throws Exception { + EntityInfo entityInfo = new EntityInfo(); + entityInfo.setLastModifiedByUser(new UserInfo()); + entityInfo.setLastModifiedOnDateTime(new Date()); + for (PropertyValue value : original.getPropertyValueList()) { + value.setPropertyValueHistoryList(new ArrayList<>()); + } + for (PropertyValue value : edited.getPropertyValueList()) { + value.setPropertyValueHistoryList(new ArrayList<>()); + } + edited.setPendingPropertyValueChanges( + gov.anl.aps.cdb.portal.model.db.utilities.PropertyValueUtility + .preparePropertyValueHistory(original.getPropertyValueList(), + edited.getPropertyValueList(), entityInfo)); + return prepareSpecializedUpdateEvents(edited, new UserInfo()); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAddEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAddEventDetectionTest.java new file mode 100644 index 000000000..6f1d4723a --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAddEventDetectionTest.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineAddEvent; +import gov.anl.aps.cdb.mqtt.metadata.MachineAddChange; +import gov.anl.aps.cdb.portal.model.db.entities.EntityInfo; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.constants.ItemElementRelationshipTypeNames; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElementRelationship; +import gov.anl.aps.cdb.portal.model.db.entities.RelationshipType; +import gov.anl.aps.cdb.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineAddEventDetectionTest { + + private final TestUtility utility = new TestUtility(); + private int nextId = 100; + + @Test + public void rootCreationPublishesNullParentAfterIdAssigned() { + ItemDomainMachineDesign root = machine(null, "Root"); + + List events = persist(root); + + assertEquals(1, events.size()); + CdbMachineAddEvent event = (CdbMachineAddEvent) events.get(0); + assertEquals(root.getId().toString(), event.getMachine().getId()); + assertNull(event.getParent()); + assertEquals("operator", event.getTriggeredByUsername()); + } + + @Test + public void nestedNewNodesOnParentEditPublishOnePerNodeWithDirectParent() { + ItemDomainMachineDesign parent = saved(machine(null, "Parent")); + ItemDomainMachineDesign child = machine(null, "Child"); + ItemDomainMachineDesign grandchild = machine(null, "Grandchild"); + child(parent, child); + child(child, grandchild); + + List events = persist(parent); + + assertEquals(2, events.size()); + assertAdd(events.get(0), child, parent); + assertAdd(events.get(1), grandchild, child); + } + + @Test + public void templateStyleHierarchyUnderNewRootPublishesEveryNode() { + ItemDomainMachineDesign existingParent = saved(machine(null, "Existing")); + ItemDomainMachineDesign root = machine(null, "Instance"); + ItemDomainMachineDesign a = machine(null, "A"); + ItemDomainMachineDesign b = machine(null, "B"); + child(existingParent, root); + child(root, a); + child(root, b); + + List events = persist(root); + + assertEquals(3, events.size()); + assertAdd(events.get(0), root, existingParent); + assertAdd(events.get(1), a, root); + assertAdd(events.get(2), b, root); + } + + @Test + public void idBearingNodeNotYetInDbIsTreatedAsNew() { + ItemDomainMachineDesign parent = saved(machine(null, "Parent")); + ItemDomainMachineDesign child = machine(55, "Preassigned"); + child(parent, child); + + List events = persist(parent); + + assertEquals(1, events.size()); + assertAdd(events.get(0), child, parent); + } + + @Test + public void movingExistingNodeDoesNotPublishAdd() { + ItemDomainMachineDesign newParent = saved(machine(null, "NewParent")); + ItemDomainMachineDesign moved = saved(machine(null, "Moved")); + ItemDomainMachineDesign movedChild = saved(machine(null, "MovedChild")); + child(moved, movedChild); + child(newParent, moved); + + assertTrue(persist(newParent).isEmpty()); + } + + @Test + public void nodeReachableFromSeveralListEntriesIsPublishedOnce() { + ItemDomainMachineDesign parent = saved(machine(null, "Parent")); + ItemDomainMachineDesign child = machine(null, "Child"); + child(parent, child); + + List events = persist(parent, child); + + assertEquals(1, events.size()); + assertAdd(events.get(0), child, parent); + } + + @Test + public void newNodesInItemsToUpdateCascadeArePublished() { + ItemDomainMachineDesign root = saved(machine(null, "Root")); + ItemDomainMachineDesign other = saved(machine(null, "Other")); + ItemDomainMachineDesign added = machine(null, "Added"); + child(other, added); + root.addItemToUpdate(other); + + List events = persist(root); + + assertEquals(1, events.size()); + assertAdd(events.get(0), added, other); + } + + @Test + public void failedPersistenceLeavesNoEventsAndPendingStateIsCleared() { + ItemDomainMachineDesign root = machine(null, "Root"); + utility.prepareSpecializedCreateEvents(Collections.singletonList(root), user()); + // Simulated failure: no ID assigned. + + List events = utility.createSpecializedCreateEvents( + Collections.singletonList(root), user()); + + assertTrue(events.isEmpty()); + assertTrue(root.getPendingMachineAddChanges().isEmpty()); + } + + @Test + public void nonTemplateNodesHaveNoTemplateContext() { + CdbMachineAddEvent event = (CdbMachineAddEvent) persist(machine(null, "Plain")).get(0); + + assertNull(event.getSourceTemplate()); + assertNull(event.getTemplateAppliedToMachine()); + assertNull(event.getTemplateCreatedMachineCount()); + } + + @Test + public void templateInstantiatedUnderExistingParentSharesNewTopNode() { + ItemDomainMachineDesign parent = saved(machine(null, "Parent")); + ItemDomainMachineDesign templateTop = saved(machine(null, "Template top")); + ItemDomainMachineDesign templateChild = saved(machine(null, "Template child")); + ItemDomainMachineDesign top = fromTemplate(machine(null, "Instance"), templateTop, false); + ItemDomainMachineDesign a = fromTemplate(machine(null, "A"), templateChild, false); + ItemDomainMachineDesign b = fromTemplate(machine(null, "B"), templateChild, false); + child(parent, top); + child(top, a); + child(a, b); + + List events = persist(parent); + + assertEquals(3, events.size()); + assertTemplate(events.get(0), templateTop, top, 3); + assertTemplate(events.get(1), templateChild, top, 3); + assertTemplate(events.get(2), templateChild, top, 3); + assertAdd(events.get(0), top, parent); + } + + @Test + public void templateAssignedToExistingMachineReportsThatMachine() { + ItemDomainMachineDesign template = saved(machine(null, "Template")); + ItemDomainMachineDesign templateChild = saved(machine(null, "Template child")); + ItemDomainMachineDesign existing = fromTemplate(saved(machine(null, "Existing")), template, false); + ItemDomainMachineDesign a = fromTemplate(machine(null, "A"), templateChild, false); + ItemDomainMachineDesign b = fromTemplate(machine(null, "B"), templateChild, false); + child(existing, a); + child(existing, b); + + List events = persist(existing); + + assertEquals(2, events.size()); + assertTemplate(events.get(0), templateChild, existing, 2); + assertTemplate(events.get(1), templateChild, existing, 2); + } + + @Test + public void newTemplateUnderOlderTemplateInstanceStartsNewApplication() { + ItemDomainMachineDesign oldTemplate = saved(machine(null, "Old template")); + ItemDomainMachineDesign newTemplate = saved(machine(null, "New template")); + ItemDomainMachineDesign olderInstance = fromTemplate(saved(machine(null, "Older")), oldTemplate, true); + ItemDomainMachineDesign added = fromTemplate(machine(null, "Added"), newTemplate, false); + child(olderInstance, added); + + List events = persist(olderInstance); + + assertEquals(1, events.size()); + assertTemplate(events.get(0), newTemplate, added, 1); + } + + @Test + public void templateUnderNewPlaceholderAppliesToTemplateNodeOnly() { + ItemDomainMachineDesign template = saved(machine(null, "Template")); + ItemDomainMachineDesign placeholder = machine(null, "Placeholder"); + ItemDomainMachineDesign instance = fromTemplate(machine(null, "Instance"), template, false); + child(placeholder, instance); + + List events = persist(placeholder); + + assertEquals(2, events.size()); + assertNull(((CdbMachineAddEvent) events.get(0)).getTemplateAppliedToMachine()); + assertTemplate(events.get(1), template, instance, 1); + } + + @Test + public void separateApplicationsInOneListSaveAreCountedSeparately() { + ItemDomainMachineDesign template = saved(machine(null, "Template")); + ItemDomainMachineDesign first = fromTemplate(machine(null, "First"), template, false); + ItemDomainMachineDesign second = fromTemplate(machine(null, "Second"), template, false); + child(first, fromTemplate(machine(null, "First child"), template, false)); + + List events = persist(first, second); + + assertEquals(3, events.size()); + assertTemplate(events.get(0), template, first, 2); + assertTemplate(events.get(1), template, first, 2); + assertTemplate(events.get(2), template, second, 1); + } + + private List persist(ItemDomainMachineDesign... roots) { + List entities = Arrays.asList(roots); + utility.prepareSpecializedCreateEvents(entities, user()); + for (ItemDomainMachineDesign root : entities) { + for (MachineAddChange change : root.getPendingMachineAddChanges()) { + ItemDomainMachineDesign machine = change.getMachine(); + if (machine.getId() == null) { + machine.setId(nextId++); + } + utility.persistedIds.add(machine.getId()); + } + } + return utility.createSpecializedCreateEvents(entities, user()); + } + + private ItemDomainMachineDesign saved(ItemDomainMachineDesign machine) { + machine.setId(nextId++); + utility.persistedIds.add(machine.getId()); + return machine; + } + + private static void assertAdd(MqttEvent value, ItemDomainMachineDesign machine, + ItemDomainMachineDesign parent) { + CdbMachineAddEvent event = (CdbMachineAddEvent) value; + assertEquals(machine.getId().toString(), event.getMachine().getId()); + assertEquals(parent.getId().toString(), event.getParent().getId()); + } + + private static void assertTemplate(MqttEvent value, ItemDomainMachineDesign template, + ItemDomainMachineDesign appliedTo, int count) { + CdbMachineAddEvent event = (CdbMachineAddEvent) value; + assertEquals(template.getId().toString(), event.getSourceTemplate().getId()); + assertEquals(appliedTo.getId().toString(), event.getTemplateAppliedToMachine().getId()); + assertEquals(Integer.valueOf(count), event.getTemplateCreatedMachineCount()); + } + + // Mirrors ItemControllerUtility.addCreatedFromTemplateRelationshipToItem. + private static ItemDomainMachineDesign fromTemplate(ItemDomainMachineDesign machine, + ItemDomainMachineDesign template, boolean relationshipSaved) { + ItemElementRelationship relationship = new ItemElementRelationship(); + relationship.setRelationshipType(new RelationshipType(1, + ItemElementRelationshipTypeNames.template.getValue())); + relationship.setFirstItemElement(machine.getSelfElement()); + relationship.setSecondItemElement(template.getSelfElement()); + if (relationshipSaved) { + relationship.setId(900); + } + machine.setItemElementRelationshipList(new ArrayList<>(Collections.singletonList(relationship))); + return machine; + } + + private static UserInfo user() { + UserInfo user = new UserInfo(); + user.setUsername("operator"); + return user; + } + + private static ItemDomainMachineDesign machine(Integer id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + item.setItemElementMemberList(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + item.setEntityInfo(new EntityInfo()); + return item; + } + + private static void child(ItemDomainMachineDesign parent, ItemDomainMachineDesign contained) { + ItemElement element = new ItemElement(); + element.setParentItem(parent); + element.setContainedItem(contained); + element.setName("E" + parent.getFullItemElementList().size()); + parent.getFullItemElementList().add(element); + contained.getItemElementMemberList().add(element); + parent.resetItemElementVars(); + } + + private static final class TestUtility extends ItemDomainMachineDesignControllerUtility { + private final Set persistedIds = new HashSet<>(); + + @Override + protected boolean isMachinePersisted(ItemDomainMachineDesign machine) { + return machine.getId() != null && persistedIds.contains(machine.getId()); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAssignedItemEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAssignedItemEventDetectionTest.java new file mode 100644 index 000000000..6e2e094fa --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineAssignedItemEventDetectionTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineAssignedItemEvent; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineAssignedItemEventDetectionTest { + + @Test + public void detectsAssignReplaceAndUnassign() throws Exception { + ItemDomainCatalog first = catalog(10, "First"); + ItemDomainCatalog second = catalog(11, "Second"); + assertTransition(null, first); + assertTransition(first, second); + assertTransition(first, null); + } + + @Test + public void detectsInstalledStateOnlyChange() throws Exception { + ItemDomainCatalog assigned = catalog(10, "Assigned"); + ItemDomainMachineDesign original = machine(1, assigned, true); + ItemDomainMachineDesign edited = machine(1, assigned, false); + TestUtility utility = new TestUtility(); + + utility.detect(original, edited); + CdbMachineAssignedItemEvent event = event(utility.events(edited)); + + assertTrue(event.isOldInstalled()); + assertFalse(event.isNewInstalled()); + } + + @Test + public void suppressesNoOp() throws Exception { + ItemDomainCatalog assigned = catalog(10, "Assigned"); + ItemDomainMachineDesign original = machine(1, assigned, true); + ItemDomainMachineDesign edited = machine(1, assigned, true); + TestUtility utility = new TestUtility(); + + utility.detect(original, edited); + + assertTrue(utility.events(edited).isEmpty()); + } + + private static void assertTransition(Item oldItem, Item newItem) throws Exception { + ItemDomainMachineDesign original = machine(1, oldItem, true); + ItemDomainMachineDesign edited = machine(1, newItem, true); + TestUtility utility = new TestUtility(); + utility.detect(original, edited); + CdbMachineAssignedItemEvent event = event(utility.events(edited)); + assertEquals(oldItem == null ? null : oldItem.getId().toString(), + event.getOldAssignedItem() == null ? null : event.getOldAssignedItem().getId()); + assertEquals(newItem == null ? null : newItem.getId().toString(), + event.getNewAssignedItem() == null ? null : event.getNewAssignedItem().getId()); + } + + private static CdbMachineAssignedItemEvent event(List events) { + assertEquals(1, events.size()); + return (CdbMachineAssignedItemEvent) events.get(0); + } + + private static ItemDomainMachineDesign machine(int id, Item assigned, boolean installed) { + ItemDomainMachineDesign machine = new ItemDomainMachineDesign(); + initialize(machine, id, "Machine"); + machine.setAssignedItem(assigned); + machine.setIsHoused(installed); + return machine; + } + + private static ItemDomainCatalog catalog(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + initialize(item, id, name); + return item; + } + + private static void initialize(Item item, int id, String name) { + item.setId(id); + item.setName(name); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } + + private static final class TestUtility extends ItemDomainMachineDesignControllerUtility { + void detect(ItemDomainMachineDesign original, ItemDomainMachineDesign edited) { + prepareMachineAssignedItemChange(original, edited); + } + + List events(ItemDomainMachineDesign edited) throws Exception { + return prepareSpecializedUpdateEvents(edited, null); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineRepresentedElementEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineRepresentedElementEventDetectionTest.java new file mode 100644 index 000000000..eb4a667ea --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineRepresentedElementEventDetectionTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineRepresentedElementEvent; +import gov.anl.aps.cdb.portal.model.db.entities.Item; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainCatalog; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineRepresentedElementEventDetectionTest { + + @Test + public void detectsSetReplaceAndClear() throws Exception { + ItemElement first = element(10, "E1"); + ItemElement second = element(11, "E2"); + assertTransition(null, first); + assertTransition(first, second); + assertTransition(first, null); + } + + @Test + public void includesPropagatedInstanceEventWithTemplateContext() throws Exception { + ItemElement oldElement = element(10, "E1"); + ItemElement newElement = element(11, "E2"); + ItemDomainMachineDesign template = machine(1, newElement); + ItemDomainMachineDesign instance = machine(2, newElement); + instance.setPendingRepresentedElementChange(oldElement, template); + template.addItemToUpdate(instance); + TestUtility utility = new TestUtility(); + + List events = utility.events(template); + + assertEquals(1, events.size()); + CdbMachineRepresentedElementEvent event + = (CdbMachineRepresentedElementEvent) events.get(0); + assertEquals("2", event.getMachine().getId()); + assertEquals("1", event.getSourceTemplate().getId()); + assertEquals("10", event.getOldRepresentedElement().getId()); + assertEquals("11", event.getNewRepresentedElement().getId()); + } + + @Test + public void suppressesNoOp() throws Exception { + ItemElement represented = element(10, "E1"); + ItemDomainMachineDesign original = machine(1, represented); + ItemDomainMachineDesign edited = machine(1, represented); + TestUtility utility = new TestUtility(); + + utility.detect(original, edited); + + assertTrue(utility.events(edited).isEmpty()); + } + + private static void assertTransition(ItemElement oldElement, ItemElement newElement) + throws Exception { + ItemDomainMachineDesign original = machine(1, oldElement); + ItemDomainMachineDesign edited = machine(1, newElement); + TestUtility utility = new TestUtility(); + utility.detect(original, edited); + List events = utility.events(edited); + assertEquals(1, events.size()); + CdbMachineRepresentedElementEvent event + = (CdbMachineRepresentedElementEvent) events.get(0); + assertEquals(oldElement == null ? null : oldElement.getId().toString(), + event.getOldRepresentedElement() == null + ? null : event.getOldRepresentedElement().getId()); + assertEquals(newElement == null ? null : newElement.getId().toString(), + event.getNewRepresentedElement() == null + ? null : event.getNewRepresentedElement().getId()); + } + + private static ItemElement element(int id, String name) { + ItemDomainCatalog assembly = catalog(100 + id, "Assembly"); + ItemDomainCatalog part = catalog(200 + id, "Part"); + ItemElement element = new ItemElement(); + element.setId(id); + element.setName(name); + element.setParentItem(assembly); + element.setContainedItem(part); + return element; + } + + private static ItemDomainMachineDesign machine(int id, ItemElement represented) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + initialize(item, id, "Machine"); + item.setRepresentsCatalogElement(represented); + return item; + } + + private static ItemDomainCatalog catalog(int id, String name) { + ItemDomainCatalog item = new ItemDomainCatalog(); + initialize(item, id, name); + return item; + } + + private static void initialize(Item item, int id, String name) { + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + } + + private static final class TestUtility extends ItemDomainMachineDesignControllerUtility { + void detect(ItemDomainMachineDesign original, ItemDomainMachineDesign edited) { + prepareMachineRepresentedElementChange(original, edited); + } + + List events(ItemDomainMachineDesign edited) throws Exception { + return prepareSpecializedUpdateEvents(edited, null); + } + } +} diff --git a/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineTrashEventDetectionTest.java b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineTrashEventDetectionTest.java new file mode 100644 index 000000000..71ecec5e7 --- /dev/null +++ b/tools/developer_tools/code_testing/CdbWebPortalTest/src/test/java/gov/anl/aps/cdb/portal/controllers/utilities/MachineTrashEventDetectionTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.cdb.portal.controllers.utilities; + +import gov.anl.aps.cdb.base.mqtt.MqttEvent; +import gov.anl.aps.cdb.mqtt.CdbMachineTrashEvent; +import gov.anl.aps.cdb.mqtt.CdbMqttTopic; +import gov.anl.aps.cdb.mqtt.metadata.MachineTrashChange; +import gov.anl.aps.cdb.portal.model.db.entities.ItemDomainMachineDesign; +import gov.anl.aps.cdb.portal.model.db.entities.ItemElement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class MachineTrashEventDetectionTest { + + @Test + public void createsOneEventPerAffectedMachine() throws Exception { + ItemDomainMachineDesign root = machine(1, "Root"); + ItemDomainMachineDesign child = machine(2, "Child"); + root.setPendingTrashChange(new MachineTrashChange( + CdbMqttTopic.MACHINE_DESIGN_TRASH, root, 2)); + child.setPendingTrashChange(new MachineTrashChange( + CdbMqttTopic.MACHINE_DESIGN_TRASH, root, 2)); + TestUtility utility = new TestUtility(); + + List rootEvents = utility.events(root); + List childEvents = utility.events(child); + + assertEquals(1, rootEvents.size()); + assertEquals(1, childEvents.size()); + CdbMachineTrashEvent childEvent = (CdbMachineTrashEvent) childEvents.get(0); + assertEquals("1", childEvent.getRequestedHierarchyRoot().getId()); + assertEquals(2, childEvent.getAffectedMachineCount()); + } + + @Test + public void itemWithoutPendingTransitionProducesNoEvent() throws Exception { + assertTrue(new TestUtility().events(machine(1, "Root")).isEmpty()); + } + + private static ItemDomainMachineDesign machine(int id, String name) { + ItemDomainMachineDesign item = new ItemDomainMachineDesign(); + item.setId(id); + item.setName(name); + item.setEntityTypeListFromApi(new ArrayList<>()); + ItemElement self = new ItemElement(); + self.setParentItem(item); + item.setFullItemElementList(new ArrayList<>(Collections.singletonList(self))); + return item; + } + + private static final class TestUtility extends ItemDomainMachineDesignControllerUtility { + List events(ItemDomainMachineDesign item) throws Exception { + return prepareSpecializedUpdateEvents(item, null); + } + } +} diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/README.md b/tools/developer_tools/portal_testing/PythonSeleniumTest/README.md index 46ab8155d..6d6f7e56a 100644 --- a/tools/developer_tools/portal_testing/PythonSeleniumTest/README.md +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/README.md @@ -24,3 +24,50 @@ From the repository root: source setup.sh make test ``` + +`make test` runs the suite in parallel with `-m "not mqtt"`, then runs the MQTT tests below +serially (`pytest tests/ -m mqtt -p no:xdist`). Set `CDB_TEST_MQTT=false` to skip them. + +## MQTT tests + +These tests are marked `mqtt` and build on `tests/mqtt_base.py`. Each one fires portal events +through the UI with the fewest steps needed, then checks the broker receives them: + +| Test | UI action | Topics | +| --- | --- | --- | +| `test_mqtt_catalog.py::test_catalog_item_and_property_lifecycle` | create catalog item, add/edit/delete a property, delete the item | `cdb/entity/add`, `cdb/entity/update`, `cdb/item/property/add`, `/update`, `/delete`, `cdb/entity/delete` | +| `test_mqtt_catalog.py::test_catalog_item_log_lifecycle` | create catalog item, add a log, attach `AnlLogo.png`, edit and delete the log, delete the item | `cdb/item/log/add`, `cdb/item/log/attachment/add`, `cdb/item/log/update`, `/delete` | +| `test_mqtt_machine_design.py::test_machine_add_parent_and_child` | create machine + child placeholder | `cdb/machine-design/add` (top level has `parent: null`; no template fields) | +| `test_mqtt_machine_design.py::test_machine_add_from_template` | create machine, add child From Template `Template_Test {nn}` | `cdb/machine-design/add` with `sourceTemplate`, `templateAppliedToMachine`, `templateCreatedMachineCount` | +| `test_mqtt_machine_design.py::test_machine_trash_and_restore` | create machine + child, Move to Trash, Put back | `cdb/entity/add`, `cdb/machine-design/trash`, `/restore` | +| `test_mqtt_machine_design.py::test_machine_assign_link_and_move` | assign catalog `Test Item Assembly` to a machine, link its child to an assembly part, drag another machine onto it | `cdb/machine-design/assigned-item/update`, `/represented-element/update`, `/move` (and no `/add` for the move) | +| `test_mqtt_inventory.py::test_inventory_location_details` | edit location details in place on inventory item 56 | `cdb/item/location/update` | +| `test_mqtt_inventory.py::test_inventory_assembly_element` | change element `E1` of inventory item 56 from item 45 to 41 and back | `cdb/inventory/assembly-element/update` | + +Together these cover all 18 topics. Trash and restore have no REST route, so they are covered +only here. The other 16 are also covered through the API in +`tools/developer_tools/python-client/test/mqtt_test.py`. Payloads of the newer topics are +described in `tools/developer_tools/python-client/README.md` ("MQTT event payloads"). Template instantiation has no REST +route, so the template fields of `cdb/machine-design/add` are covered only here. + +Everything the tests create is uniquely named (`MQTT ... `). Machine hierarchies are left in +trash and catalog items are deleted. Inventory item 56's location details and element `E1` are +restored in `finally`. If cleanup fails, the test prints what to remove by hand. + +They change shared state, so run them serially and never under `-n`. Additional requirements: + +- `paho-mqtt` (v2) in the Selenium environment: `python -m pip install paho-mqtt`; +- the shared helper `tools/developer_tools/python-client/test/mqtt_test_support.py`, which is + found automatically by relative path; +- the broker, Payara connector (`cdb/MQTT/resource`) and portal prerequisites, plus the + `CDB_TEST_MQTT*` variables, described in `tools/developer_tools/python-client/README.md`. + +They are skipped if `CDB_TEST_MQTT=false`, if paho is missing, or if the broker is unreachable. + +```sh +sbin/cdb_mqtt_test.sh # API MQTT tests, then these (from repo root) + +cd tools/developer_tools/portal_testing/PythonSeleniumTest +pytest tests/ -m mqtt -p no:xdist -rs # all Selenium MQTT tests, headless +CDB_TEST_HEADLESS=false pytest tests/test_mqtt_machine_design.py -p no:xdist -rs # watch it +``` diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/catalog.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/catalog.py index e18a2980e..d1cce7d64 100644 --- a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/catalog.py +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/catalog.py @@ -34,15 +34,17 @@ class Catalog(ItemBase): def navigate_to_catalog_list(self): self._navigate_to_dropdown('catalogDropdownButton', 'componentCatalogButton', 'itemDomainCatalog/list') - def start_create_sample_catalog_item(self): + def start_create_sample_catalog_item(self, name=SAMPLE_CATALOG_ITEM_NAME, + model=SAMPLE_CATALOG_ITEM_MODEL, + alternate_name=SAMPLE_CATALOG_ITEM_ALTERNATE_NAME): self.navigate_to_catalog_list() self._wait_for_id_and_click('componentListForm:componentAddButton') nameInput = self._wait_for_id('addComponentForm:nameInputTextCreateWizard') - nameInput.send_keys(SAMPLE_CATALOG_ITEM_NAME) + nameInput.send_keys(name) - self._type_in_id('addComponentForm:itemIdentifier1ITCreateWizard', SAMPLE_CATALOG_ITEM_MODEL) - self._type_in_id('addComponentForm:itemIdentifier2ITCreateWizard', SAMPLE_CATALOG_ITEM_ALTERNATE_NAME) + self._type_in_id('addComponentForm:itemIdentifier1ITCreateWizard', model) + self._type_in_id('addComponentForm:itemIdentifier2ITCreateWizard', alternate_name) self._type_in_id('addComponentForm:descriptionITACreateWizard', 'Description') def finish_create_sample_catalog_item(self): @@ -102,17 +104,35 @@ def search_for_sample_catalog_item(self): sampleItemLink.click() self._wait_for_id_and_click('componentViewForm:componentViewInfoButton') - def add_log_to_catalog_item(self): - logEntryText = 'My Awesome New Log Entry!!!' + def add_log_to_catalog_item(self, log_entry_text='My Awesome New Log Entry!!!'): + self._clear_notifications() + self._add_log_to_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, log_entry_text) - self._add_log_to_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, logEntryText) + def upload_attachment_to_first_catalog_log(self, sample_file_name='AnlLogo.png'): + self._clear_notifications() + self._upload_attachment_to_log_on_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, sample_file_name) - def add_property_to_catalog_item(self, test): - self._clear_notifications() - prop_value_text = "Catalog Test Property" + def update_first_catalog_log(self, new_text): + self._clear_notifications() + self._edit_log_on_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, new_text) + + def delete_first_catalog_log(self, log_text): + self._clear_notifications() + self._delete_log_on_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, log_text) + + def add_property_to_catalog_item(self, test, prop_value_text="Catalog Test Property"): + self._clear_notifications() self._add_property_to_item(test, self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, prop_value_text) + def update_first_catalog_property(self, new_value): + self._clear_notifications() + self._edit_property_value_on_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME, new_value) + + def delete_first_catalog_property(self): + self._clear_notifications() + self._delete_property_on_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME) + def add_image_to_catalog_item(self): self._add_image_to_item(self.VIEW_FORM_NAME, self.ENTITY_TYPE_NAME) diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/cdbSeleniumModuleBase.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/cdbSeleniumModuleBase.py index 3dbe6e9c9..8a9a8ce97 100644 --- a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/cdbSeleniumModuleBase.py +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/cdbSeleniumModuleBase.py @@ -321,6 +321,58 @@ def _add_log_to_item(self, form_name, entity_name, log_entry, needs_toggler=True newLogEntrySelector = "#%s\\3a %sLogListDataTable\\3a 0\\3a logEntryColumnCellEditor > div.ui-cell-editor-output" % (form_name, entity_name) WebDriverWait(self.driver, CdbSeleniumModuleBase.WAIT_FOR_ELEMENT_TIMEOUT).until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, newLogEntrySelector), log_entry)) + def _log_row_xpath(self, form_name, entity_name, row_index): + return '//tbody[@id="%s:%sLogListDataTable_data"]/tr[%d]' % (form_name, entity_name, row_index + 1) + + def _edit_log_on_item(self, form_name, entity_name, new_text, row_index=0, needs_toggler=True): + """Replace a log entry's text through the log table row editor.""" + if needs_toggler: + self._expand_panel(form_name, '%sViewLogEntriesPanel' % entity_name) + row_xpath = self._log_row_xpath(form_name, entity_name, row_index) + self._click_using_javascript(self._wait_for_visible_xpath( + row_xpath + '//a[contains(@class, "ui-row-editor-pencil")]')) + text_area = self._wait_for_visible_id('%s:%sLogListDataTable:%d:logEntry' % (form_name, entity_name, row_index)) + text_area.clear() + text_area.send_keys(new_text) + self._click_using_javascript(self._wait_for_visible_xpath( + row_xpath + '//a[contains(@class, "ui-row-editor-check")]')) + output_selector = "#%s\\3a %sLogListDataTable\\3a %d\\3a logEntryColumnCellEditor > div.ui-cell-editor-output" % (form_name, entity_name, row_index) + first_line = new_text.splitlines()[0] + WebDriverWait(self.driver, CdbSeleniumModuleBase.WAIT_FOR_ELEMENT_TIMEOUT).until( + EC.text_to_be_present_in_element((By.CSS_SELECTOR, output_selector), first_line)) + + def _delete_log_on_item(self, form_name, entity_name, log_text, row_index=0, needs_toggler=True): + """Delete a log entry through its trash link and confirmation dialog.""" + if needs_toggler: + self._expand_panel(form_name, '%sViewLogEntriesPanel' % entity_name) + table_id = '%s:%sLogListDataTable' % (form_name, entity_name) + self._click_using_javascript(self._wait_for_id( + '%s:%d:%sLogDestroyButton' % (table_id, row_index, entity_name))) + confirm = self._wait_for_visible_id('%s:%d:confirmYesDestroyLogEntryButton' % (table_id, row_index)) + self._click_using_javascript(confirm) + first_line = log_text.splitlines()[0] + WebDriverWait(self.driver, CdbSeleniumModuleBase.WAIT_FOR_ELEMENT_TIMEOUT).until( + lambda driver: first_line not in driver.find_element(By.ID, table_id).text) + + def _upload_attachment_to_log_on_item(self, form_name, entity_name, sample_file_name='AnlLogo.png', row_index=0, needs_toggler=True): + """Attach a file through the log upload dialog; Done saves the item.""" + if needs_toggler: + self._expand_panel(form_name, '%sViewLogEntriesPanel' % entity_name) + self._click_using_javascript(self._wait_for_id('%s:%sLogListDataTable:%d:%sLogUploadAttachmentButton' + % (form_name, entity_name, row_index, entity_name))) + dialog_xpath = '//*[contains(@id, "logAttachmentUploadDialog") and contains(@class, "ui-dialog")]' + self._wait_for_visible_xpath(dialog_xpath) + upload_input = self._find_by_xpath(dialog_xpath + '//input[@type="file"]') + upload_input.send_keys(os.path.abspath('data/' + sample_file_name)) + self._click_using_javascript(self._wait_for_visible_xpath( + dialog_xpath + '//button[contains(@class, "ui-fileupload-upload")]')) + # PrimeFaces drops the pending file row once the upload listener has run. + WebDriverWait(self.driver, CdbSeleniumModuleBase.WAIT_FOR_ELEMENT_TIMEOUT).until( + lambda driver: not driver.find_elements(By.XPATH, dialog_xpath + '//*[contains(@class, "ui-fileupload-row")]')) + self._click_using_javascript(self._wait_for_visible_xpath( + dialog_xpath + '//button[.//span[text()="Done"]]')) + self._wait_for_invisible_by_xpath(dialog_xpath) + def _add_property_to_item(self, test, form_name, entity_name, prop_value_text, needs_toggler=True): if needs_toggler: self._expand_panel(form_name, '%sViewPropertiesPanel' % entity_name) @@ -352,6 +404,32 @@ def _add_property_to_item(self, test, form_name, entity_name, prop_value_text, n test.assertEqual(value_element.text, prop_value_text, msg='%s found but expected %s for new property value.' % (value_element.text,prop_value_text )) + def _open_property_edit_dialog(self, form_name, entity_name, row_index, needs_toggler): + if needs_toggler: + self._expand_panel(form_name, '%sViewPropertiesPanel' % entity_name) + edit_id = '%s:%sPropertyListDataTable:%d:%sPropertyEditActionButton' % ( + form_name, entity_name, row_index, entity_name) + self._wait_for_id_and_click(edit_id) + self._wait_for_visible_id('%s:%sSinglePropertyEditDialog' % (form_name, entity_name)) + + def _close_property_edit_dialog(self, form_name, entity_name, button_suffix): + self._wait_for_id_and_click('%s:%sSinglePropertyEdit%s' % (form_name, entity_name, button_suffix)) + self._wait_for_invisible_by_id('%s:%sSinglePropertyEditDialog' % (form_name, entity_name)) + self._wait_for_invisible_loading_dialog() + + def _edit_property_value_on_item(self, form_name, entity_name, new_value, row_index=0, needs_toggler=True): + """Change a free-form property value through the property edit dialog.""" + self._open_property_edit_dialog(form_name, entity_name, row_index, needs_toggler) + value_input = self._wait_for_visible_id('%s:freeFormTextValue' % form_name) + value_input.clear() + value_input.send_keys(new_value) + self._close_property_edit_dialog(form_name, entity_name, 'SaveButton') + + def _delete_property_on_item(self, form_name, entity_name, row_index=0, needs_toggler=True): + """Delete a property through the property edit dialog.""" + self._open_property_edit_dialog(form_name, entity_name, row_index, needs_toggler) + self._close_property_edit_dialog(form_name, entity_name, 'DeleteButton') + def _add_image_to_item(self, form_name, entity_name, sample_file_name='AnlLogo.png', needs_toggler=True): if needs_toggler: self._expand_panel(form_name, '%sViewGalleryPanel' % entity_name) diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/inventory.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/inventory.py index 048359763..336274400 100644 --- a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/inventory.py +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/inventory.py @@ -1,4 +1,7 @@ +from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait from cdbSeleniumModules.itemBase import ItemBase @@ -31,6 +34,76 @@ class Inventory(ItemBase): 'Machine Assignment Replacement Root', 'Machine Assignment Replacement Destination'] + UI_ACTION_TIMEOUT = 30 + LOCATION_DETAILS_OUTPUT_CSS = "[id$=':locationDetails']" + LOCATION_DETAILS_EDIT_XPATH = "//a[contains(@id, 'locationDetailsViewOutputEditShowButton')]" + # Only one p:inplace editor is open at a time; its content holds the input, check and cancel. + INPLACE_CONTENT_XPATH = "//*[contains(concat(' ', @class, ' '), ' ui-inplace-content ')]" + LOCATION_DETAILS_WIDGET = 'locationDetailsViewOutputEditableWidget' + ASSIGNED_ITEM_DIALOG_ID = VIEW_FORM_NAME + ':updateAssignedInventoryItemDialog' + + def open_inventory_by_id(self, inventory_id): + self._open_inventory_by_id(inventory_id) + + def get_location_details(self): + return self.driver.find_element(By.CSS_SELECTOR, self.LOCATION_DETAILS_OUTPUT_CSS).text.strip() + + def _open_inplace_editor(self): + def visible_editor(driver): + for content in driver.find_elements(By.XPATH, self.INPLACE_CONTENT_XPATH): + if content.is_displayed(): + return content + return False + return WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT, + ignored_exceptions=(StaleElementReferenceException,)).until(visible_editor) + + def set_location_details_inline(self, details): + """Edit location details in place on the inventory view page (saves the item).""" + self._wait_for_clickable_xpath(self.LOCATION_DETAILS_EDIT_XPATH).click() + editor = self._open_inplace_editor() + details_input = editor.find_element(By.XPATH, ".//input[@type='text']") + details_input.clear() + if details: + details_input.send_keys(details) + buttons = [b for b in editor.find_elements(By.XPATH, ".//button") if b.is_displayed()] + if buttons: + buttons[0].click() # check (save); the second is cancel + else: + # Same as the check button's oncomplete: submit the inplace editor. + self.driver.execute_script("PF('%s').save()" % self.LOCATION_DETAILS_WIDGET) + WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT, + ignored_exceptions=(StaleElementReferenceException,)).until( + lambda driver: self.get_location_details() == (details or '')) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + def update_assembly_element(self, element_name, new_item_id, new_item_name): + """Assign an inventory item to an assembly element via the edit-assignment dialog. + + Rows are selected by PrimeFaces row key (item id); ``new_item_name`` is used to + filter when the item is not on the first page. + """ + edit_xpath = ("//tr[td[normalize-space(.)=%s]]//a[contains(@id, 'AssignedItemEditActionButton')]" + % self._xpath_literal(element_name)) + self._wait_for_clickable_xpath(edit_xpath).click() + self._wait_for_visible_id(self.ASSIGNED_ITEM_DIALOG_ID) + + row_xpath = ("//*[@id='%s']//tbody[contains(@id, 'ItemSelectDataTable')]/tr[@data-rk='%s']" + % (self.ASSIGNED_ITEM_DIALOG_ID, new_item_id)) + if not self.driver.find_elements(By.XPATH, row_xpath): + name_filter = self._wait_for_visible_xpath( + "//*[@id='%s']//input[contains(@id, 'NameColumn:filter')]" % self.ASSIGNED_ITEM_DIALOG_ID) + name_filter.clear() + name_filter.send_keys(new_item_name) + WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT).until( + EC.element_to_be_clickable((By.XPATH, row_xpath))).click() + # Selection re-renders the dialog panel under the loading dialog. + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + save_xpath = "//*[@id='%s']//button[.//span[text()='Save']]" % self.ASSIGNED_ITEM_DIALOG_ID + self._wait_for_clickable_xpath(save_xpath).click() + self._wait_for_invisible_by_id(self.ASSIGNED_ITEM_DIALOG_ID, timeout=self.UI_ACTION_TIMEOUT) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + def navigate_to_inventory_list(self): self._navigate_to_dropdown('inventoryDropdownButton', 'componentInstanceButton', '%s/list' % self.VIEW_BASE_NAME) diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/machineDesign.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/machineDesign.py index 6b325f2f6..e6bd143b3 100644 --- a/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/machineDesign.py +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/cdbSeleniumModules/machineDesign.py @@ -8,10 +8,12 @@ import time import csv -from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException +from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, TimeoutException from selenium.webdriver import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait from cdbSeleniumModules.cdbSeleniumModuleBase import CdbSeleniumModuleBase from cdbSeleniumModules.cdbSeleniumModuleDecorators import add_stale_protection @@ -48,6 +50,34 @@ class MachineDesign(CdbSeleniumModuleBase): IMPORT_FILE_NAME = "Machine Design Import.xlsx" EXPORT_FILE_NAME = "Machine Element Update Export.xlsx" + NAME_FILTER_ID = 'itemMachineDesignListForm:itemMachineDesignListDataTable:nameMdFilter' + # Top-level standard machine without an assignment uses this context menu. + MACHINE_DESIGN_MEMBER_CONTEXT_MENU_ID = 'machineDesignDualViewMachineDesignMemberContextMenu' + MENU_ITEM_BY_TEXT_XPATH_FORMULA = '//*[@id="itemMachineDesignListForm:%s"]//a[.//span[text()="%s"]]' + MENU_ITEM_BY_INDEX_XPATH_FORMULA = '//*[@id="itemMachineDesignListForm:%s"]/ul/li[%d]/a' + ASSIGN_CATALOG_MENU_ITEM_IDX = 2 + LINK_ASSEMBLY_PART_TEXT = 'Link to assembly part' + SET_REP_ELEMENT_DIALOG_ID = 'itemMachineDesignListForm:setRepElementDialog' + SET_REP_ELEMENT_ITEMS_XPATH = '//*[@id="%s"]//*[contains(@class, "ui-selectlistbox-item")]' % SET_REP_ELEMENT_DIALOG_ID + SET_REP_ELEMENT_SAVE_XPATH = '//*[@id="%s"]//button[.//span[text()="Save"]]' % SET_REP_ELEMENT_DIALOG_ID + DROP_CONFIRM_YES_XPATH = '//div[contains(@class, "ui-confirm-dialog")][.//div[@id="machineDesignDropConfirmDialogMessage"]]//button[.//span[text()="Yes"]]' + MOVE_TO_TRASH_YES_BUTTON_ID = 'itemMachineDesignListForm:moveToTrashDialogYesButton' + DELETED_ITEMS_VIEW = 'itemDomainMachineDesign/deletedItemsList' + # Deleted-items page ids are matched by suffix so they do not depend on its form name. + ROW_BY_NAME_XPATH_FORMULA = '//tbody[contains(@id, "itemMachineDesignListDataTable_data")]/tr[.//div[@class="mdName" and normalize-space(text())="%s"]]' + RESTORE_MENU_ITEM_XPATH = '//*[contains(@id, "deletedItemsListContextMenu")]//a[.//span[text()="Put back..."]]' + RESTORE_YES_BUTTON_XPATH = '//*[contains(@id, "restoreDeletedItemDialogPanel")]//button[.//span[text()="Yes"]]' + TEMPLATE_SELECTION_ROW_XPATH_FORMULA = '//*[@id="itemMachineDesignListForm:itemMachineDesignListDataTabletemplateSelection"]//tr[.//div[@class="mdName" and normalize-space(text())="%s"]]' + # The grid include gets no viewName, so match by id substring within the add-from-template panel. + TEMPLATE_VAR_INPUTS_XPATH = '//*[contains(@id, "addMachineDesignUsingTemplate")]//*[contains(@id, "itemDomainMachineDesignCreateFromTemplatePanelGrid")]//input[@type="text"]' + ADD_FROM_TEMPLATE_BUTTON_XPATH = '//*[@id="itemMachineDesignListForm:machineDesignDualListViewAddButtonUsingTemplate"]' + UI_ACTION_TIMEOUT = 30 + # "Assign Item to Machine Element" panel (item/private/itemSelectDataTable.xhtml, viewName assignCatalogSelection). + ASSIGN_PANEL_TABLE_KEY = 'ItemSelectDataTableassignCatalogSelection' + ASSIGN_PANEL_NAME_FILTER_XPATH = '//input[contains(@id, "%s:") and contains(@id, "NameColumn:filter")]' % ASSIGN_PANEL_TABLE_KEY + ASSIGN_PANEL_ROWS_XPATH = '//tbody[contains(@id, "%s_data")]/tr' % ASSIGN_PANEL_TABLE_KEY + ASSIGN_PANEL_SAVE_ID = 'itemMachineDesignListForm:assignCatalogToMachineDesignSave' + def __init__(self, driver): super().__init__(driver) @@ -256,42 +286,11 @@ def add_child_to_machine_design(self, parentListItemXpath, name, description, al self._wait_for_invisible_by_xpath(addBtnXpath) def assign_catalog_to_machine_design(self, parentListItemXpath, catalogName): - self._context_click_x_path(parentListItemXpath, '//*[@id="itemMachineDesignListForm:machineDesignDualViewMachineDesignMemberContextMenu"]/ul/li[2]/a') - - catalogNamefilter = self._wait_for_visible_xpath('//*[@id="itemMachineDesignListForm:itemMachineDesignItemSelectDataTable:itemMachineDesignListObjectNameColumn:filter"]') - - currentFilterText = catalogNamefilter.get_attribute('value') - - if currentFilterText != catalogName: - catalogNamefilter.clear() - catalogNamefilter.send_keys('@!!!#$#%^#@$%^@#$%%@#$&^#$%R%^#@$%@#$%#$%@#$%@@#$@#$!@%$#@$#@$!@#$%') - catalogNamefilter.clear() - time.sleep(0.5) - catalogNamefilter.send_keys(catalogName) - time.sleep(0.5) - - tBody = self._wait_for_id('itemMachineDesignListForm:itemMachineDesignItemSelectDataTable_data') - attempts = 0 - found = False - while attempts < 6: - resultCount = len(tBody.find_elements(by=By.XPATH, value='./tr')) - if resultCount == 1: - found = True; - break; - - time.sleep(0.5) - attempts += 1 - - if found: - self._click_on_xpath('//*[@id="itemMachineDesignListForm:itemMachineDesignItemSelectDataTable_data"]/tr') - else: - raise Exception('Couldn\'t find catalog item ' + catalogName) - - time.sleep(1) - - saveButtonId = 'itemMachineDesignListForm:assignCatalogToMachineDesignSave' - self._wait_for_id_and_click(saveButtonId) - self._wait_for_invisible_by_id(saveButtonId) + """Assign a catalog item to the child machine at ``parentListItemXpath``.""" + menu_item_xpath = self.MENU_ITEM_BY_INDEX_XPATH_FORMULA % ( + self.MACHINE_DESIGN_MEMBER_CONTEXT_MENU_ID, self.ASSIGN_CATALOG_MENU_ITEM_IDX) + self._context_click_x_path(parentListItemXpath, menu_item_xpath) + self.select_item_in_assign_panel(catalogName) def input_hierarchy_from_sample_file(self, csvFile='data/Rack-2018-11-06.csv', project='APS-OPS'): with open(csvFile) as csvFile: @@ -407,6 +406,170 @@ def test_detail_page(self, test): self._add_property_to_item(test, self.LIST_FORM_NAME, self.ENTITY_TYPE_NAME, "Machine Prop", False) self._add_image_to_item(self.LIST_FORM_NAME, self.ENTITY_TYPE_NAME, needs_toggler=False) + def filter_machine_by_name(self, name, expected_name=None): + """Replace any existing name filter, unlike filter_machine which appends.""" + self._wait_for_clickable_id_with_stale_protection(self.NAME_FILTER_ID) + filter_box = self._find_by_id(self.NAME_FILTER_ID) + filter_box.clear() + filter_box.send_keys(name + "\n") + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + self._wait_for_xpath(self.ROW_BY_NAME_XPATH_FORMULA % (expected_name or name)) + + def add_machine_design_with_child(self, name, child_name, description, project='APS-OPS'): + """Create a top-level machine with one placeholder child from the list page.""" + self.add_machine_design(name, description, project) + self.filter_machine_by_name(name) + parent_xpath = self._find_x_path_for_machine_design_item([name]) + self.add_child_to_machine_design(parent_xpath, child_name, description) + + def add_child_from_template(self, parent_name, template_name, var_value): + """Instantiate ``template_name`` under top-level ``parent_name``, filling every name variable.""" + self.filter_machine_by_name(parent_name) + parent_xpath = self._find_x_path_for_machine_design_item([parent_name]) + menu_item_xpath = self.MENU_ITEM_BY_TEXT_XPATH_FORMULA % ( + self.MACHINE_DESIGN_CONTEXT_MENU_ID, 'From Template') + self._context_click_x_path(parent_xpath, menu_item_xpath) + + wait = WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT) + wait.until(EC.element_to_be_clickable( + (By.XPATH, self.TEMPLATE_SELECTION_ROW_XPATH_FORMULA % template_name))).click() + # Selection re-renders the panel with one input per template name variable. + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + inputs = wait.until(EC.visibility_of_all_elements_located( + (By.XPATH, self.TEMPLATE_VAR_INPUTS_XPATH))) + for var_input in inputs: + var_input.clear() + var_input.send_keys(var_value) + + add_button = self._wait_until_enabled_by_xpath(self.ADD_FROM_TEMPLATE_BUTTON_XPATH) + add_button.click() + self._wait_for_invisible_by_xpath(self.ADD_FROM_TEMPLATE_BUTTON_XPATH) + + def _context_menu_id_for(self, hierarchy): + # Machines without an assignment: top-level vs member menu. + return self.MACHINE_DESIGN_CONTEXT_MENU_ID if len(hierarchy) == 1 \ + else self.MACHINE_DESIGN_MEMBER_CONTEXT_MENU_ID + + def assign_catalog_to_machine(self, hierarchy, catalog_name, filter_text): + """Assign a catalog item to an unassigned machine via the context menu. + + ``filter_text`` must match every name in ``hierarchy`` so none is filtered out. + """ + self.filter_machine_by_name(filter_text, expected_name=hierarchy[0]) + item_xpath = self._find_x_path_for_machine_design_item(hierarchy) + menu_item_xpath = self.MENU_ITEM_BY_INDEX_XPATH_FORMULA % ( + self._context_menu_id_for(hierarchy), self.ASSIGN_CATALOG_MENU_ITEM_IDX) + self._context_click_x_path(item_xpath, menu_item_xpath) + self.select_item_in_assign_panel(catalog_name) + + def link_child_to_assembly_part(self, parent_name, child_name, filter_text): + """Link an unassigned child to the first assembly part of its parent's catalog. + + Returns the selected element name. + """ + self.filter_machine_by_name(filter_text, expected_name=parent_name) + child_xpath = self._find_x_path_for_machine_design_item([parent_name, child_name]) + menu_item_xpath = self.MENU_ITEM_BY_TEXT_XPATH_FORMULA % ( + self.MACHINE_DESIGN_MEMBER_CONTEXT_MENU_ID, self.LINK_ASSEMBLY_PART_TEXT) + self._context_click_x_path(child_xpath, menu_item_xpath) + + wait = WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT) + wait.until(EC.visibility_of_element_located((By.ID, self.SET_REP_ELEMENT_DIALOG_ID))) + element_item = wait.until(EC.element_to_be_clickable((By.XPATH, self.SET_REP_ELEMENT_ITEMS_XPATH))) + element_name = element_item.text.strip() + element_item.click() + self._click_on_xpath(self.SET_REP_ELEMENT_SAVE_XPATH) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + # Save only refreshes the dialog contents; close it explicitly. + self.driver.execute_script("PF('setRepElementDialogWidget').hide()") + return element_name + + def drag_machine_onto(self, source_name, target_name, filter_text): + """Drag a top-level machine onto another machine and confirm the move.""" + self.filter_machine_by_name(filter_text, expected_name=target_name) + drag_xpath = '%s//*[contains(@id, "draggableMachineDesign")]' + source = self._wait_for_visible_xpath(drag_xpath % (self.ROW_BY_NAME_XPATH_FORMULA % source_name)) + target = self._wait_for_visible_xpath(drag_xpath % (self.ROW_BY_NAME_XPATH_FORMULA % target_name)) + + # Step through moves so jQuery UI registers a drag rather than a click. + ActionChains(self.driver).click_and_hold(source).move_by_offset(5, 5) \ + .move_to_element(target).move_by_offset(1, 1).release().perform() + + WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT).until( + EC.element_to_be_clickable((By.XPATH, self.DROP_CONFIRM_YES_XPATH))).click() + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + def select_item_in_assign_panel(self, item_name): + """Filter the assign panel to a uniquely named item, select it, and save.""" + wait = WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT) + name_filter = wait.until(EC.visibility_of_element_located( + (By.XPATH, self.ASSIGN_PANEL_NAME_FILTER_XPATH))) + name_filter.clear() + name_filter.send_keys(item_name) + + def single_matching_row(driver): + try: + rows = driver.find_elements(By.XPATH, self.ASSIGN_PANEL_ROWS_XPATH) + if len(rows) == 1 and item_name in rows[0].text: + return rows[0] + except StaleElementReferenceException: + pass + return False + + wait.until(single_matching_row).click() + # rowSelect + remote command re-render the panel under the loading dialog. + try: + WebDriverWait(self.driver, 3).until( + EC.visibility_of_element_located((By.ID, self.LOADING_DIALOG_ID))) + except TimeoutException: + pass + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + def row_selected(driver): + row = single_matching_row(driver) + return bool(row) and (row.get_attribute('aria-selected') == 'true' + or 'ui-state-highlight' in (row.get_attribute('class') or '')) + + wait.until(row_selected) + + wait.until(EC.element_to_be_clickable((By.ID, self.ASSIGN_PANEL_SAVE_ID))).click() + self._wait_for_invisible_by_id(self.ASSIGN_PANEL_SAVE_ID, timeout=self.UI_ACTION_TIMEOUT) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + def move_top_level_machine_to_trash(self, name, menu_id=MACHINE_DESIGN_CONTEXT_MENU_ID): + """Move a top-level machine (and its hierarchy) to trash via the context menu. + + Pass MACHINE_DESIGN_CATALOG_CONTEXT_MENU_ID when the machine has a catalog assigned. + """ + self.filter_machine_by_name(name) + item_xpath = self._find_x_path_for_machine_design_item([name]) + self._context_click_x_path( + item_xpath, self.MENU_ITEM_BY_TEXT_XPATH_FORMULA % (menu_id, 'Move to Trash')) + + yes_button = WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT).until( + EC.element_to_be_clickable((By.ID, self.MOVE_TO_TRASH_YES_BUTTON_ID))) + yes_button.click() + self._wait_for_invisible_by_id(self.MOVE_TO_TRASH_YES_BUTTON_ID, timeout=self.UI_ACTION_TIMEOUT) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + + def navigate_to_deleted_machine_design(self): + base_url = self.driver.current_url.split('/views/')[0] + self.driver.get('%s/views/%s.xhtml' % (base_url, self.DELETED_ITEMS_VIEW)) + self._wait_for_url_contains(self.DELETED_ITEMS_VIEW) + + def restore_deleted_machine(self, name): + """Put back a top-level machine from the deleted-items list.""" + row_xpath = self.ROW_BY_NAME_XPATH_FORMULA % name + WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT).until( + EC.presence_of_element_located((By.XPATH, row_xpath))) + self._context_click_x_path(row_xpath + '/td[1]', self.RESTORE_MENU_ITEM_XPATH) + + yes_button = WebDriverWait(self.driver, self.UI_ACTION_TIMEOUT).until( + EC.element_to_be_clickable((By.XPATH, self.RESTORE_YES_BUTTON_XPATH))) + yes_button.click() + self._wait_for_invisible_by_xpath(self.RESTORE_YES_BUTTON_XPATH) + self._wait_for_invisible_loading_dialog(timeout=self.UI_ACTION_TIMEOUT) + def filter_machine(self, filter_string='Test Machine'): filterbox_id = 'itemMachineDesignListForm:itemMachineDesignListDataTable:nameMdFilter' self._wait_for_clickable_id_with_stale_protection(filterbox_id) diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/conftest.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/conftest.py new file mode 100644 index 000000000..861e4a1f3 --- /dev/null +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/conftest.py @@ -0,0 +1,11 @@ +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. +""" + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "mqtt: stateful MQTT verification; run serially via sbin/cdb_mqtt_test.sh, " + "deselect from parallel runs with -m \"not mqtt\"") diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/mqtt_base.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/mqtt_base.py new file mode 100644 index 000000000..912a2c0f4 --- /dev/null +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/mqtt_base.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +Shared base for Selenium MQTT tests. Subclasses must be marked ``@pytest.mark.mqtt`` +so the parallel suite deselects them; run serially via sbin/cdb_mqtt_test.sh. +""" + +import os +import sys +import unittest + +from tests.base import CdbPortalTestCase + +# Shared subscriber helper lives with the API tests. +SUPPORT_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), '..', '..', '..', 'python-client', 'test')) +if os.path.isdir(SUPPORT_DIR) and SUPPORT_DIR not in sys.path: + sys.path.append(SUPPORT_DIR) + +try: + import mqtt_test_support +except ImportError: + mqtt_test_support = None + +ADMIN_USERNAME = 'cdb' +UI_EVENT_TIMEOUT = 30 + + +def described_as(path, name): + """Match snapshots by name; item descriptions look like ' [Item Id: N]'.""" + prefix = '%s [Item Id:' % name + return lambda payload: str( + mqtt_test_support.get_path(payload, path) or '').startswith(prefix) + + +class MqttPortalTestCase(CdbPortalTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + if mqtt_test_support is None: + raise unittest.SkipTest( + 'mqtt_test_support not importable; expected it in %s' % SUPPORT_DIR) + # Connect before the browser starts so an unavailable broker skips cheaply. + cls.mqtt = mqtt_test_support.start_collector_or_skip() + + @classmethod + def tearDownClass(cls): + cls.mqtt.stop() + super().tearDownClass() + + def wait_for_ui_event(self, topic, predicate, description): + return self.mqtt.wait_for( + topic, predicate, + timeout=max(UI_EVENT_TIMEOUT, self.mqtt.settings.timeout), + description=description) + + def assert_actor(self, event): + self.assertEqual(event.get('triggeredByUsername'), ADMIN_USERNAME) + self.assertIn('eventTimestamp', event) + self.assertNotIn('topic', event) diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_catalog.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_catalog.py new file mode 100644 index 000000000..db2494ece --- /dev/null +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_catalog.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +MQTT verification for generic entity and property events fired from the portal UI. + +Marked ``mqtt`` so the parallel suite deselects it (``-m "not mqtt"``); run it +serially via sbin/cdb_mqtt_test.sh. Skipped when CDB_TEST_MQTT=false, when +paho-mqtt is missing, or when the broker cannot be reached. +""" + +import unittest +import uuid + +import pytest + +from cdbSeleniumModules.catalog import Catalog +from tests.mqtt_base import MqttPortalTestCase, described_as, mqtt_test_support as support + +CATALOG_ENTITY_TYPE = 'ItemDomainCatalog' +ATTACHMENT_FILE = 'AnlLogo.png' # in PythonSeleniumTest/data + + +@pytest.mark.mqtt +class CatalogMqttTest(MqttPortalTestCase): + + def setUp(self): + super().setUp() + self.catalog = Catalog(self.driver) + + def test_catalog_item_and_property_lifecycle(self): + suffix = uuid.uuid4().hex[:10] + name = 'MQTT catalog %s' % suffix + property_value = 'mqtt-%s' % suffix + updated_value = property_value + '-updated' + + self.mqtt.drain() + self.catalog.start_create_sample_catalog_item( + name=name, model='MQTT %s' % suffix, alternate_name='MQTT') + self.catalog.finish_create_sample_catalog_item() + + added = self.wait_for_ui_event( + support.TOPIC_ENTITY_ADD, + support.all_of(lambda p: p.get('entityType') == CATALOG_ENTITY_TYPE, + described_as('entityDescription', name)), + 'add of %s' % name) + self.assertEqual(added['operation'], 'add') + self.assert_actor(added) + item_id = added['entityId'] + + deleted = False + try: + self.mqtt.drain() + self.catalog.add_property_to_catalog_item(self, prop_value_text=property_value) + prop = self.wait_for_ui_event( + support.TOPIC_PROPERTY_ADD, + support.all_of(support.id_at('itemId', item_id), + lambda p: support.get_path(p, 'newProperty.value') == property_value), + 'property %s on item %s' % (property_value, item_id)) + self.assertEqual(prop['operation'], 'add') + self.assertEqual(prop['itemType'], CATALOG_ENTITY_TYPE) + self.assertIsNone(prop.get('oldProperty')) + self.assert_actor(prop) + + # Saving the property updates the item, which also fires the generic update. + updated_item = self.wait_for_ui_event( + support.TOPIC_ENTITY_UPDATE, + support.all_of(support.id_at('entityId', item_id), + lambda p: p.get('entityType') == CATALOG_ENTITY_TYPE), + 'update of item %s' % item_id) + self.assertEqual(updated_item['operation'], 'update') + self.assert_actor(updated_item) + + property_id = prop['newProperty'].get('propertyValueId') + on_property = support.all_of( + support.id_at('itemId', item_id), + lambda p: support.get_path(p, 'oldProperty.value') in (property_value, updated_value)) + + self.mqtt.drain() + self.catalog.update_first_catalog_property(updated_value) + prop = self.wait_for_ui_event( + support.TOPIC_PROPERTY_UPDATE, on_property, + 'property update on item %s' % item_id) + self.assertEqual(prop['operation'], 'update') + self.assertEqual(prop['oldProperty']['value'], property_value) + self.assertEqual(prop['newProperty']['value'], updated_value) + if property_id is not None: + self.assertEqual(str(prop['newProperty']['propertyValueId']), str(property_id)) + self.assert_actor(prop) + + self.mqtt.drain() + self.catalog.delete_first_catalog_property() + prop = self.wait_for_ui_event( + support.TOPIC_PROPERTY_DELETE, on_property, + 'property delete on item %s' % item_id) + self.assertEqual(prop['operation'], 'delete') + self.assertEqual(prop['oldProperty']['value'], updated_value) + self.assertIsNone(prop.get('newProperty')) + self.assert_actor(prop) + + self.mqtt.drain() + self.catalog.delete_current_item() + deleted = True + removed = self.wait_for_ui_event( + support.TOPIC_ENTITY_DELETE, + support.all_of(support.id_at('entityId', item_id), + lambda p: p.get('entityType') == CATALOG_ENTITY_TYPE), + 'delete of item %s' % item_id) + self.assertEqual(removed['operation'], 'delete') + self.assert_actor(removed) + finally: + if not deleted: + print('Cleanup: delete catalog item "%s" (id %s) manually' % (name, item_id)) + + + def create_catalog_item(self, label): + suffix = uuid.uuid4().hex[:10] + name = 'MQTT catalog %s %s' % (label, suffix) + self.mqtt.drain() + self.catalog.start_create_sample_catalog_item( + name=name, model='MQTT %s' % suffix, alternate_name='MQTT') + self.catalog.finish_create_sample_catalog_item() + added = self.wait_for_ui_event( + support.TOPIC_ENTITY_ADD, + support.all_of(lambda p: p.get('entityType') == CATALOG_ENTITY_TYPE, + described_as('entityDescription', name)), + 'add of %s' % name) + return suffix, name, added['entityId'] + + def test_catalog_item_log_lifecycle(self): + suffix, name, item_id = self.create_catalog_item('log') + log_text = 'MQTT log %s' % suffix + updated_text = log_text + ' updated' + deleted = False + try: + self.mqtt.drain() + self.catalog.add_log_to_catalog_item(log_text) + added = self.wait_for_ui_event( + support.TOPIC_LOG_ADD, + support.all_of(support.id_at('itemId', item_id), + lambda p: support.get_path(p, 'newLog.text') == log_text), + 'log add on item %s' % item_id) + self.assertEqual(added['operation'], 'add') + self.assertEqual(added['itemType'], CATALOG_ENTITY_TYPE) + self.assertIsNone(added.get('oldLog')) + log_id = added['newLog']['logId'] + self.assertIsNotNone(log_id, msg='Added log has no id') + self.assert_actor(added) + + self.mqtt.drain() + self.catalog.upload_attachment_to_first_catalog_log(ATTACHMENT_FILE) + attached = self.wait_for_ui_event( + support.TOPIC_LOG_ATTACHMENT_ADD, + support.all_of(support.id_at('itemId', item_id), + support.id_at('logId', log_id)), + 'attachment on log %s' % log_id) + self.assertEqual(attached['operation'], 'add') + self.assertTrue(attached['attachment']['name'], msg='Attachment has no stored name') + self.assertEqual(attached['attachment']['originalFilename'], ATTACHMENT_FILE) + # Scalar metadata only: no file bytes, DB id or path. + self.assertEqual(set(attached['attachment']), {'name', 'originalFilename'}) + self.assert_actor(attached) + # Attachment-only saves are not log-entry updates. + self.mqtt.assert_no_message( + support.TOPIC_LOG_UPDATE, support.id_at('newLog.logId', log_id), + within=2.0, description='log update for attachment upload') + + self.mqtt.drain() + self.catalog.update_first_catalog_log(updated_text) + updated = self.wait_for_ui_event( + support.TOPIC_LOG_UPDATE, + support.all_of(support.id_at('itemId', item_id), + support.id_at('newLog.logId', log_id)), + 'log update %s' % log_id) + self.assertEqual(updated['operation'], 'update') + self.assertEqual(updated['textDiff'], '-%s\n+%s\n' % (log_text, updated_text)) + self.assertTrue(updated['newLog']['textOmitted']) + self.assert_actor(updated) + + self.mqtt.drain() + self.catalog.delete_first_catalog_log(updated_text) + removed = self.wait_for_ui_event( + support.TOPIC_LOG_DELETE, + support.all_of(support.id_at('itemId', item_id), + support.id_at('oldLog.logId', log_id)), + 'log delete %s' % log_id) + self.assertEqual(removed['operation'], 'delete') + self.assertEqual(removed['oldLog']['text'], updated_text) + self.assertIsNone(removed.get('newLog')) + self.assert_actor(removed) + + self.catalog.delete_current_item() + deleted = True + finally: + if not deleted: + print('Cleanup: delete catalog item "%s" (id %s) manually' % (name, item_id)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_inventory.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_inventory.py new file mode 100644 index 000000000..4b135a53e --- /dev/null +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_inventory.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +MQTT verification for inventory location and assembly events fired from the portal UI. + +Uses standard test-database inventory item 56 and restores it afterwards. Marked +``mqtt`` so the parallel suite deselects it; run serially via sbin/cdb_mqtt_test.sh. +""" + +import unittest +import uuid + +import pytest + +from cdbSeleniumModules.inventory import Inventory +from tests.mqtt_base import MqttPortalTestCase, mqtt_test_support as support + +# Standard test-database fixtures (see python-client test/cdb_test_base.py). +INVENTORY_ID = 56 +ELEMENT_NAME = 'E1' +ORIGINAL_ITEM_ID, ORIGINAL_ITEM_NAME = 45, 'Unit: 0005' +REPLACEMENT_ITEM_ID, REPLACEMENT_ITEM_NAME = 41, 'Unit: 0001' + + +def on_element(element_name): + return lambda p: element_name in (support.get_path(p, 'assemblyElement.name'), + support.get_path(p, 'catalogElement.name')) + + +@pytest.mark.mqtt +class InventoryMqttTest(MqttPortalTestCase): + + def setUp(self): + super().setUp() + self.inventory = Inventory(self.driver) + self.inventory.open_inventory_by_id(INVENTORY_ID) + + def test_inventory_location_details(self): + original = self.inventory.get_location_details() + new_details = 'mqtt-%s' % uuid.uuid4().hex[:10] + changed = False + try: + self.mqtt.drain() + self.inventory.set_location_details_inline(new_details) + changed = True + event = self.wait_for_ui_event( + support.TOPIC_ITEM_LOCATION, + support.all_of(support.id_at('item.id', INVENTORY_ID), + lambda p: p.get('newLocationDetails') == new_details), + 'location details on item %s' % INVENTORY_ID) + self.assertEqual(event['item']['domain'], 'Inventory') + self.assertEqual(event.get('oldLocationDetails'), original or None) + self.assert_actor(event) + finally: + if changed: + try: + self.inventory.set_location_details_inline(original) + except Exception as ex: + print('Cleanup failed; reset location details of item %s to "%s": %s' + % (INVENTORY_ID, original, ex)) + + def wait_for_assembly_event(self, new_item_id): + return self.wait_for_ui_event( + support.TOPIC_INVENTORY_ASSEMBLY_ELEMENT, + support.all_of(support.id_at('inventoryAssembly.id', INVENTORY_ID), + on_element(ELEMENT_NAME), + support.id_at('newAssignedInventoryItem.id', new_item_id)), + 'element %s on item %s -> %s' % (ELEMENT_NAME, INVENTORY_ID, new_item_id)) + + def test_inventory_assembly_element(self): + changed = False + try: + self.mqtt.drain() + self.inventory.update_assembly_element( + ELEMENT_NAME, REPLACEMENT_ITEM_ID, REPLACEMENT_ITEM_NAME) + changed = True + event = self.wait_for_assembly_event(REPLACEMENT_ITEM_ID) + self.assertEqual(str(event['oldAssignedInventoryItem']['id']), str(ORIGINAL_ITEM_ID), + msg='Test database not at baseline for element %s' % ELEMENT_NAME) + self.assert_actor(event) + + self.mqtt.drain() + self.inventory.update_assembly_element( + ELEMENT_NAME, ORIGINAL_ITEM_ID, ORIGINAL_ITEM_NAME) + changed = False + event = self.wait_for_assembly_event(ORIGINAL_ITEM_ID) + self.assertEqual(str(event['oldAssignedInventoryItem']['id']), str(REPLACEMENT_ITEM_ID)) + self.assert_actor(event) + finally: + if changed: + try: + self.inventory.update_assembly_element( + ELEMENT_NAME, ORIGINAL_ITEM_ID, ORIGINAL_ITEM_NAME) + except Exception as ex: + print('Cleanup failed; reassign %s on item %s to item %s: %s' + % (ELEMENT_NAME, INVENTORY_ID, ORIGINAL_ITEM_ID, ex)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_machine_design.py b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_machine_design.py new file mode 100644 index 000000000..57821eb93 --- /dev/null +++ b/tools/developer_tools/portal_testing/PythonSeleniumTest/tests/test_mqtt_machine_design.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +MQTT verification for machine-design events fired from the portal UI. + +Marked ``mqtt`` so the parallel suite deselects it (``-m "not mqtt"``); run it +serially via sbin/cdb_mqtt_test.sh. Skipped when CDB_TEST_MQTT=false, when +paho-mqtt is missing, or when the broker cannot be reached. +""" + +import unittest +import uuid + +import pytest + +from cdbSeleniumModules.machineDesign import MachineDesign +from tests.mqtt_base import MqttPortalTestCase, described_as, mqtt_test_support as support + +MACHINE_DOMAIN = 'Machine Design' +MACHINE_ENTITY_TYPE = 'ItemDomainMachineDesign' +PROJECT = 'APS-U' +CATALOG_NAME = 'Test Item Assembly' # unique catalog item (id 2) in the test database +AFFECTED_COUNT = 2 # parent + one child +# Test-database template with one template child; both names use the {nn} variable. +TEMPLATE_NAME = 'Template_Test {nn}' +TEMPLATE_CHILD_NAME = 'child {nn}' + + +@pytest.mark.mqtt +class MachineDesignMqttTest(MqttPortalTestCase): + + def setUp(self): + super().setUp() + self.machine_design = MachineDesign(self.driver) + + def create_hierarchy(self, label): + suffix = uuid.uuid4().hex[:10] + parent_name = 'MQTT %s parent %s' % (label, suffix) + child_name = 'MQTT %s child %s' % (label, suffix) + self.machine_design.navigate_to_machine_design() + self.machine_design.add_machine_design_with_child( + parent_name, child_name, 'Created by MQTT Selenium test', project=PROJECT) + return suffix, parent_name, child_name + + def trash_quietly(self, name, menu_id=MachineDesign.MACHINE_DESIGN_CONTEXT_MENU_ID): + # Leave disposable hierarchies in trash; never mask the original failure. + try: + self.machine_design.navigate_to_machine_design() + self.machine_design.move_top_level_machine_to_trash(name, menu_id) + except Exception as ex: + print('Cleanup failed; move "%s" to trash manually: %s' % (name, ex)) + + def assert_trash_event(self, event, operation, machine_name, root_name): + self.assertEqual(event['operation'], operation) + self.assertTrue(described_as('affectedMachine.description', machine_name)(event)) + self.assertEqual(event['affectedMachine']['domain'], MACHINE_DOMAIN) + self.assertTrue(described_as('requestedHierarchyRoot.description', root_name)(event), + msg='Unexpected hierarchy root: %s' % event.get('requestedHierarchyRoot')) + self.assertEqual(event['affectedMachineCount'], AFFECTED_COUNT) + self.assert_actor(event) + + def test_machine_add_parent_and_child(self): + self.mqtt.drain() + _, parent_name, child_name = self.create_hierarchy('add') + try: + parent = self.wait_for_ui_event( + support.TOPIC_MD_ADD, + described_as('machine.description', parent_name), + 'machine add of %s' % parent_name) + self.assertEqual(parent['machine']['domain'], MACHINE_DOMAIN) + self.assertIn('parent', parent) + self.assertIsNone(parent['parent'], msg='Top-level machine must have null parent') + self.assertNotIn('oldParent', parent) + self.assertNotIn('newParent', parent) + self.assertNotIn('sourceTemplate', parent) + self.assertNotIn('templateAppliedToMachine', parent) + self.assertNotIn('templateCreatedMachineCount', parent) + self.assert_actor(parent) + + child = self.wait_for_ui_event( + support.TOPIC_MD_ADD, + described_as('machine.description', child_name), + 'machine add of %s' % child_name) + self.assertEqual(str(child['parent']['id']), str(parent['machine']['id'])) + self.assertNotEqual(str(child['machine']['id']), str(parent['machine']['id'])) + self.assert_actor(child) + + # Adding the child must not re-announce the existing parent. + self.mqtt.assert_no_message( + support.TOPIC_MD_ADD, + described_as('machine.description', parent_name), + within=2.0, description='parent %s re-announced' % parent_name) + finally: + self.trash_quietly(parent_name) + + def test_machine_add_from_template(self): + suffix = uuid.uuid4().hex[:10] + parent_name = 'MQTT template parent %s' % suffix + top_name = TEMPLATE_NAME.replace('{nn}', suffix) + child_name = TEMPLATE_CHILD_NAME.replace('{nn}', suffix) + self.machine_design.navigate_to_machine_design() + self.machine_design.add_machine_design( + parent_name, 'Created by MQTT Selenium test', PROJECT) + try: + self.mqtt.drain() + self.machine_design.add_child_from_template(parent_name, TEMPLATE_NAME, suffix) + + top = self.wait_for_ui_event( + support.TOPIC_MD_ADD, described_as('machine.description', top_name), + 'machine add of %s' % top_name) + self.assertTrue(described_as('parent.description', parent_name)(top), + msg='Unexpected parent: %s' % top.get('parent')) + self.assertTrue(described_as('sourceTemplate.description', TEMPLATE_NAME)(top), + msg='Unexpected template: %s' % top.get('sourceTemplate')) + # The new top node is the machine the template was applied to. + self.assertEqual(str(top['templateAppliedToMachine']['id']), str(top['machine']['id'])) + self.assertEqual(top['templateCreatedMachineCount'], 2) + self.assert_actor(top) + + child = self.wait_for_ui_event( + support.TOPIC_MD_ADD, described_as('machine.description', child_name), + 'machine add of %s' % child_name) + self.assertEqual(str(child['parent']['id']), str(top['machine']['id'])) + self.assertTrue(described_as('sourceTemplate.description', TEMPLATE_CHILD_NAME)(child), + msg='Unexpected template: %s' % child.get('sourceTemplate')) + self.assertEqual(str(child['templateAppliedToMachine']['id']), str(top['machine']['id'])) + self.assertEqual(child['templateCreatedMachineCount'], 2) + self.assert_actor(child) + + # The existing parent is neither announced nor treated as the template target. + self.mqtt.assert_no_message( + support.TOPIC_MD_ADD, described_as('machine.description', parent_name), + within=2.0, description='existing parent %s announced' % parent_name) + finally: + self.trash_quietly(parent_name) + + def test_machine_trash_and_restore(self): + self.mqtt.drain() + _, parent_name, child_name = self.create_hierarchy('trash') + + # Creating through the UI fires the generic add event. + added = self.wait_for_ui_event( + support.TOPIC_ENTITY_ADD, + support.all_of(lambda p: p.get('entityType') == MACHINE_ENTITY_TYPE, + described_as('entityDescription', parent_name)), + 'add of %s' % parent_name) + self.assertEqual(added['operation'], 'add') + self.assert_actor(added) + + active = True + try: + self.mqtt.drain() + self.machine_design.move_top_level_machine_to_trash(parent_name) + active = False + for name in (parent_name, child_name): + event = self.wait_for_ui_event( + support.TOPIC_MD_TRASH, + described_as('affectedMachine.description', name), + 'trash of %s' % name) + self.assert_trash_event(event, 'trash', name, parent_name) + + self.mqtt.drain() + self.machine_design.navigate_to_deleted_machine_design() + self.machine_design.restore_deleted_machine(parent_name) + active = True + for name in (parent_name, child_name): + event = self.wait_for_ui_event( + support.TOPIC_MD_RESTORE, + described_as('affectedMachine.description', name), + 'restore of %s' % name) + self.assert_trash_event(event, 'restore', name, parent_name) + finally: + if active: + self.trash_quietly(parent_name) + + def test_machine_assign_link_and_move(self): + suffix, parent_name, child_name = self.create_hierarchy('chain') + mover_name = 'MQTT chain mover %s' % suffix + self.machine_design.add_machine_design( + mover_name, 'Created by MQTT Selenium test', PROJECT) + parent_has_catalog = False + mover_moved = False + try: + # Assign a catalog assembly to the parent. + self.mqtt.drain() + self.machine_design.assign_catalog_to_machine( + [parent_name], CATALOG_NAME, filter_text=suffix) + parent_has_catalog = True + event = self.wait_for_ui_event( + support.TOPIC_MD_ASSIGNED_ITEM, + described_as('machine.description', parent_name), + 'assignment on %s' % parent_name) + self.assertEqual(event['machine']['domain'], MACHINE_DOMAIN) + self.assertIsNone(event.get('oldAssignedItem')) + self.assertTrue(described_as('newAssignedItem.description', CATALOG_NAME)(event), + msg='Unexpected assigned item: %s' % event.get('newAssignedItem')) + self.assertEqual(event['newAssignedItem']['domain'], 'Catalog') + self.assert_actor(event) + + # Link the child to one of that assembly's parts. + self.mqtt.drain() + element_name = self.machine_design.link_child_to_assembly_part( + parent_name, child_name, filter_text=suffix) + event = self.wait_for_ui_event( + support.TOPIC_MD_REPRESENTED_ELEMENT, + described_as('machine.description', child_name), + 'represented element on %s' % child_name) + self.assertIsNone(event.get('oldRepresentedElement')) + self.assertEqual(event['newRepresentedElement']['name'], element_name) + self.assertTrue( + described_as('newRepresentedElement.parentItem.description', CATALOG_NAME)(event), + msg='Unexpected element parent: %s' % event['newRepresentedElement'].get('parentItem')) + self.assert_actor(event) + + # Drag the top-level mover onto the parent. + self.mqtt.drain() + self.machine_design.drag_machine_onto(mover_name, parent_name, filter_text=suffix) + mover_moved = True + event = self.wait_for_ui_event( + support.TOPIC_MD_MOVE, + described_as('machine.description', mover_name), + 'move of %s' % mover_name) + self.mqtt.assert_no_message( + support.TOPIC_MD_ADD, described_as('machine.description', mover_name), + within=2.0, description='move announced as new machine %s' % mover_name) + self.assertIsNone(event.get('oldParent')) + self.assertTrue(described_as('newParent.description', parent_name)(event), + msg='Unexpected new parent: %s' % event.get('newParent')) + self.assert_actor(event) + finally: + menu_id = (MachineDesign.MACHINE_DESIGN_CATALOG_CONTEXT_MENU_ID if parent_has_catalog + else MachineDesign.MACHINE_DESIGN_CONTEXT_MENU_ID) + self.trash_quietly(parent_name, menu_id) + if not mover_moved: + self.trash_quietly(mover_name) + +if __name__ == '__main__': + unittest.main() diff --git a/tools/developer_tools/python-client/README.md b/tools/developer_tools/python-client/README.md index f357e1d4b..819faa773 100644 --- a/tools/developer_tools/python-client/README.md +++ b/tools/developer_tools/python-client/README.md @@ -83,3 +83,82 @@ uv run pytest test/ # requires the portal running on localho `sbin/cdb_test.sh` (invoked by `make test`) regenerates the client and runs this as part of the full suite. + +### MQTT integration tests + +`test/mqtt_test.py` subscribes to `cdb/#` on a real broker, performs API mutations, and checks +the MQTT events the portal publishes. It needs `paho-mqtt` (v2) in the active environment. It is +not a declared dependency, so install it yourself (for example `uv pip install paho-mqtt` or +`pip install paho-mqtt`). + +Prerequisites (developer-owned; nothing here provisions or reconfigures them): + +- an MQTT broker reachable from both Payara and your shell; +- the Payara connector resource `cdb/MQTT/resource` pointing at that broker + (`sbin/cdb_create_mqtt_configuration.sh`, then `sbin/cdb_configure_mqtt_service.sh`); +- the portal deployed with the MQTT changes and running on the test database (`make test-db`). + +`make test` (`sbin/cdb_test.sh`) runs these with the API suite and runs the Selenium MQTT tests +serially after the parallel Selenium run. To run only the MQTT tests (API, then Selenium) from +the repo root: + +```sh +source setup.sh # optional if already sourced; the runner keeps an existing environment +sbin/cdb_mqtt_test.sh # extra args are passed to pytest, e.g. -k location +``` + +Or run only the API module: + +```sh +uv run pytest test/mqtt_test.py -rs +``` + +| Variable | Default | Purpose | +| --- | --- | --- | +| `CDB_TEST_MQTT` | `true` | `false` skips all MQTT tests | +| `CDB_TEST_MQTT_HOST` | `localhost` | broker host for the test subscriber | +| `CDB_TEST_MQTT_PORT` | `1883` | broker port | +| `CDB_TEST_MQTT_USERNAME` / `CDB_TEST_MQTT_PASSWORD` | unset | optional broker credentials | +| `CDB_TEST_MQTT_TIMEOUT` | `10` | seconds to wait for connect, subscribe and each event | +| `CDB_TEST_MQTT_SKIP_SELENIUM` | unset | `true` makes `sbin/cdb_mqtt_test.sh` run only the API module | + +The subscriber does not read the connector's `$CDB_INSTALL_DIR/etc/.mqtt.conf`. If the broker +requires authentication, export the same values, or another account allowed to subscribe to +`cdb/#`, in `CDB_TEST_MQTT_USERNAME`/`CDB_TEST_MQTT_PASSWORD`. + +#### MQTT event payloads + +All events carry `triggeredByUsername` and `eventTimestamp`; the topic is never repeated in the +payload. Item references are snapshots with `id`, `description`, `domain`, `domain_id` and +`entityTypes`. Free text is capped at 16 KiB; `diffTruncated` is `true` when text or a diff was cut. + +| Topic | Payload | +| --- | --- | +| `cdb/machine-design/add` | `machine`, `parent` (`null` for a top-level machine). Only for nodes cloned from a template: `sourceTemplate`, `templateAppliedToMachine` (the new top node, or the existing machine a template was assigned to) and `templateCreatedMachineCount` (add events from that template application). Published alongside `cdb/entity/add` for directly created machines; nodes saved through a parent update get only this event. Moves never publish it. | +| `cdb/item/log/add` | `operation`, `itemId`, `itemType`, `itemDescription`, `newLog` | +| `cdb/item/log/update` | as above with `oldLog` and `newLog` (text omitted, `textOmitted: true`) and `textDiff` (`-`/`+` lines; `null` when only dates or topic changed). Attachment-only edits publish no update. | +| `cdb/item/log/delete` | as above with `oldLog` | +| `cdb/item/log/attachment/add` | `operation`, `itemId`, `itemType`, `itemDescription`, `logId`, `attachment` (`name`, the unique stored file name, and `originalFilename`; never the file contents or DB id). A new log with attachments publishes its `cdb/item/log/add` first, then one event per attachment. | + +Order within one save: the generic `cdb/entity/*` event, then property, log update/delete and +existing-log attachment events, then `cdb/machine-design/add` (in tree order), then each new log +followed by its attachment events. Each machine, log and attachment is announced once per save. + +Log snapshots contain `logId`, `text` (bounded; omitted on updates), `textOmitted`, +`enteredOnDateTime`, `effectiveFromDateTime`, `effectiveToDateTime`, `enteredByUsername`, +`logTopicId` and `logTopicName`. Only logs on the item itself are reported, never system logs. + +The log edit/delete tests use `itemApi.update_log_entry_for_item` and +`itemApi.delete_log_entry_from_item`. Regenerate the client after deploying +(`./generatePyClient.sh http://localhost:8080/cdb`) if those methods are missing. + +The tests change standard test-database records (inventory item 56, machines 93 and 112) and +restore them in `finally`. Placeholder and top-level control machines, locations, properties and +log entries (on catalog item 2) they create are uniquely named and removed afterwards. The +broker round-trip check publishes one non-retained message on `cdb/test-probe/`. + +The tests are skipped, with the reason shown by `-rs`, if `paho-mqtt` is missing or the subscriber +cannot connect. That keeps `make test` working on hosts without a broker. Once the subscriber is +connected, a missing event fails the test. The failure message lists the unmatched messages it +did capture, plus likely connector causes. The runner never builds, deploys, restarts services, +or replaces the database. diff --git a/tools/developer_tools/python-client/test/mqtt_test.py b/tools/developer_tools/python-client/test/mqtt_test.py new file mode 100644 index 000000000..5617368b9 --- /dev/null +++ b/tools/developer_tools/python-client/test/mqtt_test.py @@ -0,0 +1,715 @@ +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +MQTT integration tests: perform API mutations against the deployed portal and +verify the events it publishes through Payara's cdb/MQTT/resource connector. + +Skipped when CDB_TEST_MQTT=false, when paho-mqtt is missing, or when the broker +cannot be reached. See README.md ("MQTT integration tests"). +""" + +import os +import unittest + +from cdbApi.exceptions import OpenApiException +from cdbApi.models import ( + LogEntryEditInformation, + NewLocationInformation, + NewMachinePlaceholderOptions, + PropertyValue, + RepresentingAssemblyElementForMachineInformation, + SimpleLocationInformation, + UpdateMachineAssignedItemInformation, +) + +from test.cdb_test_base import CdbTestBase +from test.mqtt_test_support import ( + TOPIC_ENTITY_ADD, + TOPIC_ENTITY_DELETE, + TOPIC_ENTITY_UPDATE, + TOPIC_INVENTORY_ASSEMBLY_ELEMENT, + TOPIC_ITEM_LOCATION, + TOPIC_LOG_ADD, + TOPIC_LOG_ATTACHMENT_ADD, + TOPIC_LOG_DELETE, + TOPIC_LOG_UPDATE, + TOPIC_MD_ADD, + TOPIC_MD_ASSIGNED_ITEM, + TOPIC_MD_MOVE, + TOPIC_MD_REPRESENTED_ELEMENT, + TOPIC_PROPERTY_ADD, + TOPIC_PROPERTY_DELETE, + TOPIC_PROPERTY_UPDATE, + all_of, + get_path, + id_at, + start_collector_or_skip, +) + +LOCATION_ENTITY_TYPE = "ItemDomainLocation" +MACHINE_ENTITY_TYPE = "ItemDomainMachineDesign" +MACHINE_DOMAIN = "Machine Design" +# Grace period for asserting that no specialized event follows the generic update. +NO_EVENT_GRACE_SECONDS = 2.0 + + +class MqttIntegrationTest(CdbTestBase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.mqtt = start_collector_or_skip() + + @classmethod + def tearDownClass(cls): + cls.mqtt.stop() + super().tearDownClass() + + def setUp(self): + super().setUp() + self.mqtt.drain() + + # -- helpers ----------------------------------------------------------- + + def assert_actor(self, payload, username=None): + self.assertEqual(payload.get("triggeredByUsername"), + username or self.ADMIN_USERNAME, msg="Unexpected event actor") + self.assertIn("eventTimestamp", payload) + self.assertNotIn("topic", payload, msg="Topic must not be serialized into payload") + + def wait_for_entity_event(self, topic, entity_id, entity_type): + return self.mqtt.wait_for( + topic, + all_of(id_at("entityId", entity_id), + lambda p: p.get("entityType") == entity_type), + description="%s id=%s" % (entity_type, entity_id)) + + def assert_item_snapshot(self, snapshot, item_id, msg=None): + self.assertIsNotNone(snapshot, msg=msg) + self.assertEqual(str(snapshot.get("id")), str(item_id), msg=msg) + + def delete_item_quietly(self, item_id): + try: + self.itemApi.delete_item_by_id(item_id) + except OpenApiException: + pass + + # -- harness ----------------------------------------------------------- + + def test_broker_round_trip(self): + payload = self.mqtt.probe() + self.assertIn("probe", payload) + + # -- generic entity events --------------------------------------------- + + def test_entity_add_update_delete(self): + self.loginAsAdmin() + name = "MQTT test location %s" % self.gen_unique_name() + location_id = None + deleted = False + try: + self.mqtt.drain() + location = self.itemApi.create_location( + new_location_information=NewLocationInformation( + location_name=name, location_type="Building")) + location_id = location.id + + added = self.wait_for_entity_event( + TOPIC_ENTITY_ADD, location_id, LOCATION_ENTITY_TYPE) + self.assertEqual(added["operation"], "add") + self.assertIn(name, added["entityDescription"]) + self.assertIn("[Item Id: %s]" % location_id, added["entityDescription"]) + self.assert_actor(added) + + self.mqtt.drain() + new_name = name + " renamed" + item = self.itemApi.get_item_by_id(location_id) + item.name = new_name + self.itemApi.update_item_details(item=item) + + updated = self.wait_for_entity_event( + TOPIC_ENTITY_UPDATE, location_id, LOCATION_ENTITY_TYPE) + self.assertEqual(updated["operation"], "update") + self.assertIn(new_name, updated["entityDescription"]) + self.assert_actor(updated) + + self.mqtt.drain() + self.itemApi.delete_item_by_id(location_id) + deleted = True + + removed = self.wait_for_entity_event( + TOPIC_ENTITY_DELETE, location_id, LOCATION_ENTITY_TYPE) + self.assertEqual(removed["operation"], "delete") + self.assertIn(new_name, removed["entityDescription"]) + self.assert_actor(removed) + finally: + if location_id is not None and not deleted: + self.delete_item_quietly(location_id) + + # -- location events --------------------------------------------------- + + def set_item_location(self, item_id, location_id, details): + self.itemApi.update_item_location( + simple_location_information=SimpleLocationInformation( + locatable_item_id=item_id, location_item_id=location_id, + location_details=details)) + + def test_item_location_update(self): + item_id = self.INVENTORY_ITEM_ID + original = self.itemApi.get_item_location(item_id) + original_location_id = original.location_item.id if original.location_item else None + original_details = original.location_details or None + + new_location_id = self.LOC_ROOM_104_ID + if new_location_id == original_location_id: + new_location_id = self.LOC_BUILDING_1_ID + new_details = "mqtt-%s" % self.gen_unique_name() + + self.loginAsAdmin() + changed = False + try: + self.mqtt.drain() + self.set_item_location(item_id, new_location_id, new_details) + changed = True + + event = self.mqtt.wait_for( + TOPIC_ITEM_LOCATION, + all_of(id_at("item.id", item_id), id_at("newLocation.id", new_location_id)), + description="item %s -> location %s" % (item_id, new_location_id)) + self.assert_item_snapshot(event["item"], item_id) + self.assertEqual(event["item"]["domain"], "Inventory") + if original_location_id is None: + self.assertIsNone(event.get("oldLocation")) + else: + self.assert_item_snapshot(event["oldLocation"], original_location_id) + self.assertEqual(event["oldLocation"]["domain"], "Location") + self.assert_item_snapshot(event["newLocation"], new_location_id) + self.assertEqual(event.get("oldLocationDetails"), original_details) + self.assertEqual(event.get("newLocationDetails"), new_details) + self.assert_actor(event) + finally: + if changed: + self.set_item_location(item_id, original_location_id, original_details) + + restored = self.itemApi.get_item_location(item_id) + restored_id = restored.location_item.id if restored.location_item else None + self.assertEqual(restored_id, original_location_id, msg="Location not restored") + + # -- inventory assembly events ----------------------------------------- + + def find_assembly_element(self, assembly_id, element_name): + hierarchy = self.itemApi.get_item_hierarchy_by_id(assembly_id) + for child in hierarchy.child_items: + if child.element_name == element_name: + contained_id = child.item.id if child.item else None + return child.element_id, contained_id + self.fail("Element %s not found on item %s" % (element_name, assembly_id)) + + def wait_for_assembly_event(self, assembly_id, element_id, new_item_id): + return self.mqtt.wait_for( + TOPIC_INVENTORY_ASSEMBLY_ELEMENT, + all_of(id_at("inventoryAssembly.id", assembly_id), + id_at("assemblyElement.id", element_id), + id_at("newAssignedInventoryItem.id", new_item_id)), + description="assembly %s element %s -> %s" % (assembly_id, element_id, new_item_id)) + + def assert_assembly_event(self, event, assembly_id, element_id, old_item_id, new_item_id): + self.assert_item_snapshot(event["inventoryAssembly"], assembly_id) + self.assertEqual(str(event["assemblyElement"]["id"]), str(element_id)) + self.assertEqual(event["assemblyElement"]["name"], self.INVENTORY_ITEM_ELEMENT_NAME) + if old_item_id is None: + self.assertIsNone(event.get("oldAssignedInventoryItem")) + else: + self.assert_item_snapshot(event["oldAssignedInventoryItem"], old_item_id) + if new_item_id is None: + self.assertIsNone(event.get("newAssignedInventoryItem")) + else: + self.assert_item_snapshot(event["newAssignedInventoryItem"], new_item_id) + self.assert_actor(event) + + def test_inventory_assembly_element_assign_clear_restore(self): + assembly_id = self.INVENTORY_ITEM_ID + element_id, original_id = self.find_assembly_element( + assembly_id, self.INVENTORY_ITEM_ELEMENT_NAME) + self.assertEqual(original_id, self.INVENTORY_FIRST_CONTAINED_ITEM_ID, + msg="Test database not at baseline for assembly element") + replacement_id = self.INVENTORY_FIRST_CONTAINED_NEW_ITEM_ID + + self.loginAsAdmin() + try: + self.mqtt.drain() + self.itemApi.update_contained_item(element_id, replacement_id) + event = self.wait_for_assembly_event(assembly_id, element_id, replacement_id) + self.assert_assembly_event(event, assembly_id, element_id, original_id, replacement_id) + + self.mqtt.drain() + self.itemApi.clear_contained_item(element_id) + event = self.wait_for_assembly_event(assembly_id, element_id, None) + self.assert_assembly_event(event, assembly_id, element_id, replacement_id, None) + + self.mqtt.drain() + self.itemApi.update_contained_item(element_id, original_id) + event = self.wait_for_assembly_event(assembly_id, element_id, original_id) + self.assert_assembly_event(event, assembly_id, element_id, None, original_id) + finally: + _, current_id = self.find_assembly_element( + assembly_id, self.INVENTORY_ITEM_ELEMENT_NAME) + if current_id != original_id: + self.itemApi.update_contained_item(element_id, original_id) + + # -- machine design events --------------------------------------------- + + def set_assigned_item(self, machine_id, item_id, installed=None): + # The route rejects isInstalled unless the assignment is an inventory item. + return self.machineDesignApi.update_assigned_item( + UpdateMachineAssignedItemInformation( + md_item_id=machine_id, assigned_item_id=item_id, is_installed=installed)) + + def get_assignment(self, machine_id): + """Return (assigned item id, is inventory, installed flag) for a machine.""" + machine = self.machineDesignApi.get_machine_design_item_by_id(machine_id) + item = machine.assigned_item + is_inventory = bool(item and item.domain and item.domain.name == "Inventory") + return (item.id if item else None), is_inventory, bool(machine.is_housed) + + def restore_assignment(self, machine_id, item_id, is_inventory, installed): + self.set_assigned_item(machine_id, item_id, installed if is_inventory else None) + + def test_machine_assigned_item_update(self): + machine_id = self.MACHINE_DESIGN_ID + original_id, original_is_inventory, original_installed = self.get_assignment(machine_id) + if original_id == self.INVENTORY_ITEM_ID: + new_id, new_installed, expected_new_installed = self.CATALOG_ITEM_ID, None, True + else: + # Planned (not installed) inventory makes the installed transition observable. + new_id, new_installed, expected_new_installed = self.INVENTORY_ITEM_ID, False, False + + self.loginAsAdmin() + changed = False + try: + self.mqtt.drain() + self.set_assigned_item(machine_id, new_id, new_installed) + changed = True + event = self.mqtt.wait_for( + TOPIC_MD_ASSIGNED_ITEM, + all_of(id_at("machine.id", machine_id), id_at("newAssignedItem.id", new_id)), + description="machine %s -> item %s" % (machine_id, new_id)) + self.assert_item_snapshot(event["machine"], machine_id) + self.assertEqual(event["machine"]["domain"], MACHINE_DOMAIN) + if original_id is None: + self.assertIsNone(event.get("oldAssignedItem")) + else: + self.assert_item_snapshot(event["oldAssignedItem"], original_id) + self.assert_item_snapshot(event["newAssignedItem"], new_id) + self.assertEqual(event["oldInstalled"], original_installed) + self.assertEqual(event["newInstalled"], expected_new_installed) + self.assert_actor(event) + + self.mqtt.drain() + self.restore_assignment(machine_id, original_id, original_is_inventory, + original_installed) + changed = False + event = self.mqtt.wait_for( + TOPIC_MD_ASSIGNED_ITEM, + all_of(id_at("machine.id", machine_id), id_at("newAssignedItem.id", original_id)), + description="machine %s -> item %s (revert)" % (machine_id, original_id)) + self.assert_item_snapshot(event["oldAssignedItem"], new_id) + self.assertEqual(event["oldInstalled"], expected_new_installed) + self.assertEqual(event["newInstalled"], original_installed) + self.assert_actor(event) + finally: + if changed: + self.restore_assignment(machine_id, original_id, original_is_inventory, + original_installed) + + def test_machine_assigned_item_noop_publishes_no_specialized_event(self): + machine_id = self.MACHINE_DESIGN_ID + current_id, is_inventory, installed = self.get_assignment(machine_id) + + self.loginAsAdmin() + self.mqtt.drain() + # Re-saving the same assignment still updates the entity, which acts as a barrier. + self.restore_assignment(machine_id, current_id, is_inventory, installed) + self.wait_for_entity_event(TOPIC_ENTITY_UPDATE, machine_id, MACHINE_ENTITY_TYPE) + self.mqtt.assert_no_message( + TOPIC_MD_ASSIGNED_ITEM, id_at("machine.id", machine_id), + within=NO_EVENT_GRACE_SECONDS, + description="no-op assignment on machine %s" % machine_id) + + def create_placeholder(self, parent_id, name): + return self.machineDesignApi.create_placeholder( + parent_id, NewMachinePlaceholderOptions(name=name)) + + def remove_machine_quietly(self, machine_id): + try: + self.machineDesignApi.detach_machine_from_hierarchy(machine_id) + except OpenApiException: + pass + self.delete_item_quietly(machine_id) + + def wait_for_machine_add(self, machine_id): + return self.mqtt.wait_for( + TOPIC_MD_ADD, id_at("machine.id", machine_id), + description="new machine %s" % machine_id) + + def test_machine_add_nested_placeholders(self): + root_id = self.MACHINE_DESIGN_ID + suffix = self.gen_unique_name() + self.loginAsAdmin() + created = [] + try: + self.mqtt.drain() + parent = self.create_placeholder(root_id, "MQTT add parent %s" % suffix) + created.append(parent.id) + event = self.wait_for_machine_add(parent.id) + self.assert_item_snapshot(event["machine"], parent.id) + self.assertEqual(event["machine"]["domain"], MACHINE_DOMAIN) + self.assert_item_snapshot(event["parent"], root_id) + self.assertNotIn("oldParent", event) + self.assertNotIn("newParent", event) + # Template fields are omitted for non-template machines. + self.assertNotIn("sourceTemplate", event) + self.assertNotIn("templateAppliedToMachine", event) + self.assertNotIn("templateCreatedMachineCount", event) + self.assert_actor(event) + # Placeholders are cascaded through the parent, so the standard event is its update. + self.wait_for_entity_event(TOPIC_ENTITY_UPDATE, root_id, MACHINE_ENTITY_TYPE) + + child = self.create_placeholder(parent.id, "MQTT add child %s" % suffix) + created.append(child.id) + event = self.wait_for_machine_add(child.id) + self.assert_item_snapshot(event["parent"], parent.id) + self.assert_actor(event) + # The existing parent must not be re-announced when a child is added. + self.mqtt.assert_no_message( + TOPIC_MD_ADD, id_at("machine.id", parent.id), + within=NO_EVENT_GRACE_SECONDS, + description="existing parent %s re-announced" % parent.id) + finally: + for machine_id in reversed(created): + self.remove_machine_quietly(machine_id) + + def test_machine_add_root_has_null_parent(self): + suffix = self.gen_unique_name() + self.loginAsAdmin() + machine_id = None + try: + self.mqtt.drain() + # Top-level control node: a direct create with no parent. + machine = self.machineDesignApi.create_control_element(NewMachinePlaceholderOptions( + name="MQTT add root %s" % suffix, project_id=self.PROJECT_ID)) + machine_id = machine.id + event = self.wait_for_machine_add(machine_id) + self.assert_item_snapshot(event["machine"], machine_id) + self.assertEqual(event["machine"]["domain"], MACHINE_DOMAIN) + self.assertIn("parent", event) + self.assertIsNone(event["parent"]) + self.assert_actor(event) + # Standard entity add is still published alongside the specialized event. + self.wait_for_entity_event(TOPIC_ENTITY_ADD, machine_id, MACHINE_ENTITY_TYPE) + finally: + if machine_id is not None: + self.delete_item_quietly(machine_id) + + def wait_for_move(self, machine_id, new_parent_id): + return self.mqtt.wait_for( + TOPIC_MD_MOVE, + all_of(id_at("machine.id", machine_id), id_at("newParent.id", new_parent_id)), + description="machine %s -> parent %s" % (machine_id, new_parent_id)) + + def test_machine_move(self): + root_id = self.MACHINE_DESIGN_ID + suffix = self.gen_unique_name() + self.loginAsAdmin() + created = [] + try: + new_parent = self.create_placeholder(root_id, "MQTT move parent %s" % suffix) + created.append(new_parent.id) + child = self.create_placeholder(root_id, "MQTT move child %s" % suffix) + created.append(child.id) + + self.mqtt.drain() + self.machineDesignApi.move_machine(child.id, new_parent.id) + event = self.wait_for_move(child.id, new_parent.id) + self.mqtt.assert_no_message( + TOPIC_MD_ADD, id_at("machine.id", child.id), + within=NO_EVENT_GRACE_SECONDS, + description="move announced as new machine %s" % child.id) + self.assert_item_snapshot(event["machine"], child.id) + self.assertEqual(event["machine"]["domain"], MACHINE_DOMAIN) + self.assert_item_snapshot(event["oldParent"], root_id) + self.assert_item_snapshot(event["newParent"], new_parent.id) + self.assert_actor(event) + + self.mqtt.drain() + self.machineDesignApi.move_machine(child.id, root_id) + event = self.wait_for_move(child.id, root_id) + self.assert_item_snapshot(event["oldParent"], new_parent.id) + self.assert_item_snapshot(event["newParent"], root_id) + self.assert_actor(event) + finally: + for machine_id in reversed(created): + self.remove_machine_quietly(machine_id) + + def find_represented_element(self): + """Return the E1 element id of the represented machine's parent assembly.""" + hierarchy = self.machineDesignApi.get_housing_hierarchy_by_id( + self.MACHINE_TO_REPRESENT_ID) + parent_id = None + child = hierarchy.child_items[0] if hierarchy.child_items else None + while child is not None: + if child.item.id == self.MACHINE_TO_REPRESENT_ID: + parent_id = hierarchy.item.id + break + hierarchy = child + child = hierarchy.child_items[0] if hierarchy.child_items else None + self.assertIsNotNone(parent_id, msg="Parent of represented machine not found") + + parent = self.machineDesignApi.get_machine_design_item_by_id(parent_id) + assembly = self.itemApi.get_item_hierarchy_by_id(parent.assigned_item.id) + for element in assembly.child_items: + if element.element_name == self.ELEMENT_NAME_TO_REPRESENT: + return element.element_id + self.fail("Element %s not found" % self.ELEMENT_NAME_TO_REPRESENT) + + def set_represented_element(self, machine_id, element_id): + return self.machineDesignApi.update_representing_assembly_element_for_machine( + RepresentingAssemblyElementForMachineInformation( + machine_id=machine_id, assembly_element_id=element_id)) + + def get_represented_element_id(self, machine_id): + machine = self.machineDesignApi.get_machine_design_item_by_id(machine_id) + element = machine.assigned_represented_element + return element.id if element else None + + def test_machine_represented_element_set_clear(self): + machine_id = self.MACHINE_TO_REPRESENT_ID + element_id = self.find_represented_element() + restore_id = self.get_represented_element_id(machine_id) + + self.loginAsAdmin() + try: + if restore_id == element_id: + self.set_represented_element(machine_id, None) + old_id = None if restore_id == element_id else restore_id + + self.mqtt.drain() + self.set_represented_element(machine_id, element_id) + event = self.mqtt.wait_for( + TOPIC_MD_REPRESENTED_ELEMENT, + all_of(id_at("machine.id", machine_id), + id_at("newRepresentedElement.id", element_id)), + description="machine %s represents element %s" % (machine_id, element_id)) + self.assert_item_snapshot(event["machine"], machine_id) + if old_id is None: + self.assertIsNone(event.get("oldRepresentedElement")) + else: + self.assertEqual(str(event["oldRepresentedElement"]["id"]), str(old_id)) + new_element = event["newRepresentedElement"] + self.assertEqual(new_element["name"], self.ELEMENT_NAME_TO_REPRESENT) + self.assertIsNotNone(new_element.get("parentItem")) + self.assert_actor(event) + + self.mqtt.drain() + self.set_represented_element(machine_id, None) + event = self.mqtt.wait_for( + TOPIC_MD_REPRESENTED_ELEMENT, + all_of(id_at("machine.id", machine_id), + id_at("oldRepresentedElement.id", element_id)), + description="machine %s clears element %s" % (machine_id, element_id)) + self.assertIsNone(event.get("newRepresentedElement")) + self.assert_actor(event) + finally: + if self.get_represented_element_id(machine_id) != restore_id: + self.set_represented_element(machine_id, restore_id) + + # -- item property events ---------------------------------------------- + + def test_item_property_add_update_delete(self): + self.loginAsAdmin() + item_id = self.INVENTORY_ITEM_ID + property_type = self.propertyTypeApi.get_property_type_by_name( + self.TEST_PROPERTY_TYPE_NAME) + initial_value = "mqtt-%s" % self.gen_unique_name() + updated_value = initial_value + "-updated" + property_id = None + deleted = False + on_item = id_at("itemId", item_id) + try: + self.mqtt.drain() + created = self.itemApi.add_item_property_value( + item_id, property_value=PropertyValue( + property_type=property_type, value=initial_value)) + property_id = created.id + + # New property ids are not assigned until persist, so match the add by value. + added = self.mqtt.wait_for( + TOPIC_PROPERTY_ADD, + all_of(on_item, lambda p: get_path(p, "newProperty.value") == initial_value), + description="item %s value %s" % (item_id, initial_value)) + self.assertEqual(added["operation"], "add") + self.assertEqual(added["itemType"], "ItemDomainInventory") + self.assertIsNone(added.get("oldProperty")) + new_property = added["newProperty"] + self.assertEqual(new_property["propertyTypeName"], self.TEST_PROPERTY_TYPE_NAME) + self.assertEqual(str(new_property["propertyTypeId"]), str(property_type.id)) + if new_property.get("propertyValueId") is not None: + self.assertEqual(str(new_property["propertyValueId"]), str(property_id)) + self.assert_actor(added) + + self.mqtt.drain() + created.value = updated_value + self.itemApi.update_item_property_value(item_id, property_value=created) + + updated = self.mqtt.wait_for( + TOPIC_PROPERTY_UPDATE, + all_of(on_item, id_at("newProperty.propertyValueId", property_id)), + description="item %s property %s" % (item_id, property_id)) + self.assertEqual(updated["operation"], "update") + self.assertEqual(updated["oldProperty"]["value"], initial_value) + self.assertEqual(updated["newProperty"]["value"], updated_value) + self.assertEqual(str(updated["oldProperty"]["propertyValueId"]), str(property_id)) + self.assert_actor(updated) + + self.mqtt.drain() + self.propertyValueApi.delete_property_by_id(property_id) + deleted = True + + removed = self.mqtt.wait_for( + TOPIC_PROPERTY_DELETE, + all_of(on_item, id_at("oldProperty.propertyValueId", property_id)), + description="item %s property %s" % (item_id, property_id)) + self.assertEqual(removed["operation"], "delete") + self.assertEqual(removed["oldProperty"]["value"], updated_value) + self.assertIsNone(removed.get("newProperty")) + self.assert_actor(removed) + finally: + if property_id is not None and not deleted: + try: + self.propertyValueApi.delete_property_by_id(property_id) + except OpenApiException: + pass + + + # -- item log events --------------------------------------------------- + + def add_item_log(self, item_id, text): + return self.itemApi.add_log_entry_to_item( + log_entry_edit_information=LogEntryEditInformation(item_id=item_id, log_entry=text)) + + def delete_item_log_quietly(self, item_id, log_id): + try: + self.itemApi.delete_log_entry_from_item(item_id, log_id) + except OpenApiException: + pass + + def wait_for_log_event(self, topic, item_id, log_path, log_id): + return self.mqtt.wait_for( + topic, all_of(id_at("itemId", item_id), id_at(log_path, log_id)), + description="log %s on item %s" % (log_id, item_id)) + + def test_item_log_add_update_delete(self): + item_id = self.CATALOG_ITEM_ID + text = "MQTT log %s" % self.gen_unique_name() + updated_text = text + "\nsecond line" + self.loginAsAdmin() + log_id = None + try: + self.mqtt.drain() + log_id = self.add_item_log(item_id, text).id + event = self.wait_for_log_event(TOPIC_LOG_ADD, item_id, "newLog.logId", log_id) + self.assertEqual(event["operation"], "add") + self.assertIsNone(event.get("oldLog")) + self.assertEqual(event["newLog"]["text"], text) + self.assertFalse(event["newLog"]["textOmitted"]) + self.assertEqual(event["newLog"]["enteredByUsername"], self.ADMIN_USERNAME) + self.assertIsNone(event.get("textDiff")) + self.assertNotIn("attachmentList", event["newLog"]) + self.assert_actor(event) + # A new entry is an add only, never also an update. + self.mqtt.assert_no_message( + TOPIC_LOG_UPDATE, id_at("newLog.logId", log_id), + within=NO_EVENT_GRACE_SECONDS, description="update for new log %s" % log_id) + + self.mqtt.drain() + self.itemApi.update_log_entry_for_item( + log_id, log_entry_edit_information=LogEntryEditInformation( + item_id=item_id, log_entry=updated_text)) + event = self.wait_for_log_event(TOPIC_LOG_UPDATE, item_id, "newLog.logId", log_id) + self.assertEqual(event["operation"], "update") + self.assertEqual(str(event["oldLog"]["logId"]), str(log_id)) + self.assertEqual(event["textDiff"], "+second line\n") + self.assertFalse(event["diffTruncated"]) + self.assertTrue(event["oldLog"]["textOmitted"]) + self.assertIsNone(event["newLog"].get("text")) + self.assert_actor(event) + + self.mqtt.drain() + self.itemApi.delete_log_entry_from_item(item_id, log_id) + deleted_id, log_id = log_id, None + event = self.wait_for_log_event(TOPIC_LOG_DELETE, item_id, "oldLog.logId", deleted_id) + self.assertEqual(event["operation"], "delete") + self.assertEqual(event["oldLog"]["text"], updated_text) + self.assertIsNone(event.get("newLog")) + self.assert_actor(event) + finally: + if log_id is not None: + self.delete_item_log_quietly(item_id, log_id) + + def test_item_log_long_text_is_bounded(self): + item_id = self.CATALOG_ITEM_ID + limit = 16 * 1024 + text = ("MQTT long log %s " % self.gen_unique_name()).ljust(limit + 100, "x") + self.loginAsAdmin() + log_id = None + try: + self.mqtt.drain() + log_id = self.add_item_log(item_id, text).id + event = self.wait_for_log_event(TOPIC_LOG_ADD, item_id, "newLog.logId", log_id) + self.assertEqual(event["newLog"]["text"], text[:limit]) + self.assertTrue(event["diffTruncated"]) + finally: + if log_id is not None: + self.delete_item_log_quietly(item_id, log_id) + + + def test_item_log_attachment_add(self): + item_id = self.CATALOG_ITEM_ID + file_name = os.path.basename(self.SAMPLE_DOC_PATH) + self.loginAsAdmin() + log_id = None + try: + log_id = self.add_item_log(item_id, "MQTT attachment log %s" % self.gen_unique_name()).id + self.wait_for_log_event(TOPIC_LOG_ADD, item_id, "newLog.logId", log_id) + + self.mqtt.drain() + upload = self.factory.createFileUploadObject(self.SAMPLE_DOC_PATH) + result = self.itemApi.upload_log_attachment_for_item( + item_id, log_id, file_upload_object=upload) + stored_name = result.attachment_list[-1].name + event = self.mqtt.wait_for( + TOPIC_LOG_ATTACHMENT_ADD, + all_of(id_at("itemId", item_id), id_at("logId", log_id)), + description="attachment on log %s" % log_id) + self.assertEqual(event["operation"], "add") + self.assertEqual(event["attachment"]["name"], stored_name) + self.assertEqual(event["attachment"]["originalFilename"], file_name) + # Scalar metadata only: no file bytes, DB id or path. + self.assertEqual(set(event["attachment"]), {"name", "originalFilename"}) + self.assertNotIn("fileUploadObject", event) + self.assert_actor(event) + # Attachment-only edits are not log-entry updates, and the attachment is announced once. + self.mqtt.assert_no_message( + TOPIC_LOG_UPDATE, id_at("newLog.logId", log_id), + within=NO_EVENT_GRACE_SECONDS, description="log update for attachment upload") + self.mqtt.assert_no_message( + TOPIC_LOG_ATTACHMENT_ADD, id_at("logId", log_id), + within=NO_EVENT_GRACE_SECONDS, description="duplicate attachment event") + finally: + if log_id is not None: + self.delete_item_log_quietly(item_id, log_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/python-client/test/mqtt_test_support.py b/tools/developer_tools/python-client/test/mqtt_test_support.py new file mode 100644 index 000000000..d7c7a0022 --- /dev/null +++ b/tools/developer_tools/python-client/test/mqtt_test_support.py @@ -0,0 +1,337 @@ +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. + +Shared MQTT subscriber helpers for the API and Selenium integration tests. + +Kept free of other ``test.*`` imports so it can be imported either as +``test.mqtt_test_support`` (API suite) or ``mqtt_test_support`` (Selenium suite, +with ``python-client/test`` on PYTHONPATH). +""" + +import json +import os +import threading +import time +import unittest +import uuid + +TOPIC_FILTER = "cdb/#" + +TOPIC_ENTITY_ADD = "cdb/entity/add" +TOPIC_ENTITY_UPDATE = "cdb/entity/update" +TOPIC_ENTITY_DELETE = "cdb/entity/delete" +TOPIC_PROPERTY_ADD = "cdb/item/property/add" +TOPIC_PROPERTY_UPDATE = "cdb/item/property/update" +TOPIC_PROPERTY_DELETE = "cdb/item/property/delete" +TOPIC_LOG_ADD = "cdb/item/log/add" +TOPIC_LOG_UPDATE = "cdb/item/log/update" +TOPIC_LOG_DELETE = "cdb/item/log/delete" +TOPIC_LOG_ATTACHMENT_ADD = "cdb/item/log/attachment/add" +TOPIC_MD_ADD = "cdb/machine-design/add" +TOPIC_MD_ASSIGNED_ITEM = "cdb/machine-design/assigned-item/update" +TOPIC_MD_MOVE = "cdb/machine-design/move" +TOPIC_MD_REPRESENTED_ELEMENT = "cdb/machine-design/represented-element/update" +TOPIC_MD_TRASH = "cdb/machine-design/trash" +TOPIC_MD_RESTORE = "cdb/machine-design/restore" +TOPIC_ITEM_LOCATION = "cdb/item/location/update" +TOPIC_INVENTORY_ASSEMBLY_ELEMENT = "cdb/inventory/assembly-element/update" + +PUBLISH_FAILURE_HINT = ( + "The subscriber is connected, but the portal did not publish the expected event. " + "Likely causes: the Payara connector resource 'cdb/MQTT/resource' is not configured " + "(see sbin/cdb_create_mqtt_configuration.sh and sbin/cdb_configure_mqtt_service.sh); " + "the connector points at a different broker than CDB_TEST_MQTT_HOST/CDB_TEST_MQTT_PORT; " + "or the portal was not redeployed with the MQTT changes. Check the Payara server.log " + "for 'MQTT is unavailable' or 'MQTT broker is unavailable' warnings." +) + +_TRUE_VALUES = ("true", "1", "t", "y", "yes", "on") + + +def _env_flag(name, default): + value = os.getenv(name) + if value is None or value.strip() == "": + return default + return value.strip().lower() in _TRUE_VALUES + + +class MqttTestSettings: + """Subscriber settings read from CDB_TEST_MQTT_* environment variables.""" + + def __init__(self, enabled=True, host="localhost", port=1883, + username=None, password=None, timeout=10.0): + self.enabled = enabled + self.host = host + self.port = port + self.username = username + self.password = password + self.timeout = timeout + + @classmethod + def from_env(cls): + port_value = os.getenv("CDB_TEST_MQTT_PORT", "1883") + timeout_value = os.getenv("CDB_TEST_MQTT_TIMEOUT", "10") + try: + port = int(port_value) + except ValueError: + raise ValueError("CDB_TEST_MQTT_PORT must be an integer, got %r" % port_value) + try: + timeout = float(timeout_value) + except ValueError: + raise ValueError("CDB_TEST_MQTT_TIMEOUT must be a number, got %r" % timeout_value) + return cls( + enabled=_env_flag("CDB_TEST_MQTT", True), + host=os.getenv("CDB_TEST_MQTT_HOST") or "localhost", + port=port, + username=os.getenv("CDB_TEST_MQTT_USERNAME") or None, + password=os.getenv("CDB_TEST_MQTT_PASSWORD") or None, + timeout=timeout, + ) + + def describe(self): + return "%s:%s" % (self.host, self.port) + + +class MqttUnavailable(Exception): + """The broker could not be reached or the subscription was refused.""" + + +class CapturedMessage: + + def __init__(self, topic, raw, received_at): + self.topic = topic + self.raw = raw + self.received_at = received_at + self.decode_error = None + try: + self.payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as ex: + self.payload = None + self.decode_error = str(ex) + + def summary(self): + if self.payload is None: + return "%s " % (self.topic, self.decode_error) + text = json.dumps(self.payload, sort_keys=True) + if len(text) > 300: + text = text[:300] + "..." + return "%s %s" % (self.topic, text) + + +def get_path(payload, path): + """Return a nested value for a dotted path such as 'machine.id', or None.""" + value = payload + for part in path.split("."): + if not isinstance(value, dict): + return None + value = value.get(part) + return value + + +def same_id(value, expected): + """Compare ids that the events may serialize as strings.""" + if value is None or expected is None: + return value is None and expected is None + return str(value) == str(expected) + + +def id_at(path, expected): + """Predicate matching payloads whose id at ``path`` equals ``expected``.""" + return lambda payload: same_id(get_path(payload, path), expected) + + +def all_of(*predicates): + return lambda payload: all(p(payload) for p in predicates) + + +class MqttEventCollector: + """Background subscriber that buffers decoded messages for bounded waits.""" + + def __init__(self, settings, topic_filter=TOPIC_FILTER): + self.settings = settings + self.topic_filter = topic_filter + self._messages = [] + self._lock = threading.Condition() + self._connected = threading.Event() + self._subscribed = threading.Event() + self._failure = None + self._client = None + + # -- lifecycle --------------------------------------------------------- + + def start(self): + try: + import paho.mqtt.client as mqtt + except ImportError as ex: + raise MqttUnavailable("paho-mqtt is not installed (%s)" % ex) + + client_id = "cdb-mqtt-test-%s" % uuid.uuid4().hex[:8] + callback_api = getattr(mqtt, "CallbackAPIVersion", None) + if callback_api is not None: + client = mqtt.Client(callback_api.VERSION2, client_id=client_id) + else: + client = mqtt.Client(client_id=client_id) + if self.settings.username: + client.username_pw_set(self.settings.username, self.settings.password) + + client.on_connect = self._on_connect + client.on_subscribe = self._on_subscribe + client.on_message = self._on_message + self._client = client + + try: + client.connect(self.settings.host, self.settings.port, keepalive=30) + except (OSError, ValueError) as ex: + raise MqttUnavailable("cannot connect to broker %s: %s" + % (self.settings.describe(), ex)) + + client.loop_start() + if not self._connected.wait(self.settings.timeout): + self.stop() + raise MqttUnavailable("no CONNACK from broker %s within %ss (%s)" + % (self.settings.describe(), self.settings.timeout, + self._failure or "timeout")) + if self._failure: + failure = self._failure + self.stop() + raise MqttUnavailable(failure) + + client.subscribe(self.topic_filter, qos=1) + if not self._subscribed.wait(self.settings.timeout): + self.stop() + raise MqttUnavailable("no SUBACK for %s within %ss" + % (self.topic_filter, self.settings.timeout)) + if self._failure: + failure = self._failure + self.stop() + raise MqttUnavailable(failure) + return self + + def stop(self): + client = self._client + self._client = None + if client is not None: + try: + client.disconnect() + finally: + client.loop_stop() + + def probe(self, timeout=None): + """Round-trip a non-retained message through the broker on our own subscription.""" + topic = "cdb/test-probe/%s" % uuid.uuid4().hex + marker = uuid.uuid4().hex + self._client.publish(topic, json.dumps({"probe": marker}), qos=1) + message = self._wait(topic, lambda payload: payload.get("probe") == marker, timeout) + if message is None: + raise AssertionError("Probe message on %s was not echoed back by broker %s" + % (topic, self.settings.describe())) + return message.payload + + # -- paho callbacks (compatible with callback API v1 and v2) ----------- + + def _on_connect(self, client, userdata, flags, reason_code, properties=None): + if _is_failure(reason_code): + self._failure = "broker %s refused connection: %s" % ( + self.settings.describe(), reason_code) + self._connected.set() + + def _on_subscribe(self, client, userdata, mid, reason_codes, properties=None): + codes = reason_codes if isinstance(reason_codes, (list, tuple)) else [reason_codes] + for code in codes: + if _is_failure(code, subscribe=True): + self._failure = "broker refused subscription to %s: %s" % ( + self.topic_filter, code) + self._subscribed.set() + + def _on_message(self, client, userdata, message): + captured = CapturedMessage(message.topic, message.payload, time.monotonic()) + with self._lock: + self._messages.append(captured) + self._lock.notify_all() + + # -- queue access ------------------------------------------------------ + + def drain(self): + """Discard and return everything captured so far.""" + with self._lock: + drained = self._messages + self._messages = [] + return drained + + def messages(self): + with self._lock: + return list(self._messages) + + def wait_for(self, topic, predicate=None, timeout=None, description=None): + """Remove and return the first payload on ``topic`` matching ``predicate``. + + Fails the calling test with diagnostics if nothing matches in time. + """ + message = self._wait(topic, predicate, timeout) + if message is None: + raise AssertionError(self._timeout_message(topic, description, timeout)) + return message.payload + + def assert_no_message(self, topic, predicate=None, within=1.0, description=None): + """Fail if a matching message arrives within ``within`` seconds.""" + message = self._wait(topic, predicate, within) + if message is not None: + raise AssertionError("Unexpected MQTT message%s: %s" % ( + " (%s)" % description if description else "", message.summary())) + + def _wait(self, topic, predicate, timeout): + timeout = self.settings.timeout if timeout is None else timeout + deadline = time.monotonic() + timeout + with self._lock: + while True: + for index, message in enumerate(self._messages): + if message.topic != topic or message.payload is None: + continue + if predicate is None or predicate(message.payload): + del self._messages[index] + return message + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + self._lock.wait(remaining) + + def _timeout_message(self, topic, description, timeout): + timeout = self.settings.timeout if timeout is None else timeout + received = self.messages() + lines = ["No MQTT message on %s%s within %ss from broker %s." % ( + topic, " matching %s" % description if description else "", + timeout, self.settings.describe())] + if received: + lines.append("Captured but unmatched messages (%d):" % len(received)) + lines.extend(" " + m.summary() for m in received[-10:]) + else: + lines.append("No messages were captured on %s." % self.topic_filter) + if any(m.topic == topic for m in received): + # The topic is flowing, so this is a payload/correlation mismatch, not wiring. + lines.append("Messages arrived on %s but none matched; compare the payloads above " + "with the test's expectations." % topic) + else: + lines.append(PUBLISH_FAILURE_HINT) + return "\n".join(lines) + + +def _is_failure(code, subscribe=False): + # paho v2 passes ReasonCode objects; v1 passes ints (connect) or granted QoS (subscribe). + is_failure = getattr(code, "is_failure", None) + if is_failure is not None: + return bool(is_failure) + if subscribe: + return code == 0x80 + return code != 0 + + +def start_collector_or_skip(settings=None): + """Start a collector, or raise unittest.SkipTest when MQTT is disabled/unreachable.""" + settings = settings or MqttTestSettings.from_env() + if not settings.enabled: + raise unittest.SkipTest("MQTT tests disabled (CDB_TEST_MQTT=false)") + try: + return MqttEventCollector(settings).start() + except MqttUnavailable as ex: + raise unittest.SkipTest("MQTT broker unavailable, skipping: %s" % ex)