diff --git a/controller/src/robot_vision/src/rv/tracking/ObjectMatching.cpp b/controller/src/robot_vision/src/rv/tracking/ObjectMatching.cpp index 3419285c4..1564a63de 100644 --- a/controller/src/robot_vision/src/rv/tracking/ObjectMatching.cpp +++ b/controller/src/robot_vision/src/rv/tracking/ObjectMatching.cpp @@ -11,8 +11,6 @@ #include "rv/apollo/secure_matrix.hpp" #include "rv/tracking/Classification.hpp" -#include - namespace rv { namespace tracking { diff --git a/controller/src/robot_vision/src/rv/tracking/TrackManager.cpp b/controller/src/robot_vision/src/rv/tracking/TrackManager.cpp index 01e81e214..32e797672 100644 --- a/controller/src/robot_vision/src/rv/tracking/TrackManager.cpp +++ b/controller/src/robot_vision/src/rv/tracking/TrackManager.cpp @@ -3,7 +3,6 @@ #include "rv/Utils.hpp" #include "rv/tracking/TrackManager.hpp" -#include #include namespace rv { @@ -354,10 +353,6 @@ void TrackManager::updateTrackerConfig(int camera_frame_rate) mConfig.mMaxNumberOfUnreliableFrames = std::ceil(camera_frame_rate*mConfig.mMaxUnreliableTime); mConfig.mNonMeasurementFramesDynamic = std::ceil(camera_frame_rate*mConfig.mNonMeasurementTimeDynamic); mConfig.mNonMeasurementFramesStatic = std::ceil(camera_frame_rate*mConfig.mNonMeasurementTimeStatic); - std::cout << "Updated parameters for reference camera frame rate = " << camera_frame_rate << "fps" << std::endl; - std::cout << "max_unreliable_frames = " << mConfig.mMaxNumberOfUnreliableFrames << std::endl; - std::cout << "non_measurement_frames_dynamic = " << mConfig.mNonMeasurementFramesDynamic << std::endl; - std::cout << "non_measurement_frames_static = " << mConfig.mNonMeasurementFramesStatic << std::endl; } } // namespace tracking diff --git a/docs/design/tracker-service.md b/docs/design/tracker-service.md index c6f62f46f..4bac9da4c 100644 --- a/docs/design/tracker-service.md +++ b/docs/design/tracker-service.md @@ -71,10 +71,10 @@ graph LR **Topics Subscribed:** -| Topic | Description | -| ------------------------------- | ---------------------------------------------------------------------------- | -| `scenescape/data/camera/+` | Detection messages from AI pipeline with bounding boxes in pixel coordinates | -| `scenescape/cmd/scene/update/+` | Config change notifications from Manager API (dynamic mode only) | +| Topic | Description | +| -------------------------- | ---------------------------------------------------------------------------- | +| `scenescape/data/camera/+` | Detection messages from AI pipeline with bounding boxes in pixel coordinates | +| `scenescape/cmd/database` | Database change notifications from Manager API (dynamic mode only) | **Detection Message:** @@ -179,8 +179,9 @@ Scenes fetched from Manager API at startup: - Set `scenes.source: "api"` or omit `scenes` section (defaults to API mode) - Requires `infrastructure.manager` with API URL and credentials -- Subscribes to `scenescape/cmd/scene/update/{scene_id}` for change notifications +- Subscribes to `scenescape/cmd/database` for change notifications - On notification: logs change, exits gracefully (Docker restarts the service which loads new config at startup) +- Fires on any database change: scene create/update/delete, camera changes, region edits, etc. - Suitable for multi-node deployments with centralized scene management ### Observability diff --git a/sample_data/docker-compose-dl-streamer-example.yml b/sample_data/docker-compose-dl-streamer-example.yml index b494493b0..9f3707637 100644 --- a/sample_data/docker-compose-dl-streamer-example.yml +++ b/sample_data/docker-compose-dl-streamer-example.yml @@ -26,6 +26,8 @@ secrets: file: ${SECRETSDIR}/django controller.auth: environment: CONTROLLER_AUTH + controller-auth-file: + file: manager/secrets/controller.auth browser.auth: file: ${SECRETSDIR}/browser.auth calibration.auth: @@ -458,6 +460,8 @@ services: depends_on: broker: condition: service_started + web: + condition: service_healthy environment: - TRACKER_LOG_LEVEL=info - TRACKER_MQTT_HOST=broker.scenescape.intel.com @@ -465,6 +469,10 @@ services: - TRACKER_MQTT_INSECURE=false - TRACKER_MQTT_TLS_CA_CERT=/run/secrets/certs/scenescape-ca.pem - TRACKER_MQTT_TLS_VERIFY_SERVER=true + - TRACKER_MANAGER_URL=https://web.scenescape.intel.com + - TRACKER_MANAGER_AUTH_PATH=/run/secrets/controller.auth + - TRACKER_MANAGER_CA_CERT_PATH=/run/secrets/certs/scenescape-ca.pem + - TRACKER_SCENES_SOURCE=api # Override host proxy settings - Paho MQTT dont respect no_proxy var, so as a WA # tracker code detects empty vars and unsets them (see mqtt_client.cpp clearEmptyProxyVars) - http_proxy= @@ -474,12 +482,17 @@ services: secrets: - source: root-cert target: certs/scenescape-ca.pem + - source: controller-auth-file + target: /run/secrets/controller.auth read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true - restart: always + # Exit 0: graceful stop or non-retryable error (bad auth) — stay stopped + # Exit 1: retryable error (broker unavailable) — restart + # Exit 99: scene update received — restart to reload config + restart: on-failure mem_limit: ${TRACKER_MEM_LIMIT:-512m} # Scale: ~1 CPU per 100 tracked objects. Increase TRACKER_CPUS for larger deployments. cpus: ${TRACKER_CPUS:-2.0} diff --git a/tracker/CMakeLists.txt b/tracker/CMakeLists.txt index 44b5f8a00..b060e8bbe 100644 --- a/tracker/CMakeLists.txt +++ b/tracker/CMakeLists.txt @@ -43,6 +43,7 @@ find_package(CLI11 REQUIRED) find_package(httplib REQUIRED) find_package(RapidJSON REQUIRED) find_package(PahoMqttCpp REQUIRED) +find_package(OpenSSL REQUIRED) ##################################################################### # System-provided packages @@ -76,6 +77,8 @@ set(PROJECT_SOURCE_LIST ${CMAKE_CURRENT_SOURCE_DIR}/src/cli.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/config_loader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/scene_loader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/api_scene_loader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/manager_rest_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/coordinate_transformer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/healthcheck_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/healthcheck_command.cpp @@ -122,6 +125,8 @@ target_link_libraries(${PROJECT_NAME} httplib::httplib rapidjson PahoMqttCpp::paho-mqttpp3-static + OpenSSL::SSL + OpenSSL::Crypto ) ##################################################################### diff --git a/tracker/Makefile b/tracker/Makefile index 1fca7e3a1..5a8d7e944 100644 --- a/tracker/Makefile +++ b/tracker/Makefile @@ -75,7 +75,7 @@ format-python: echo "autopep8 not found. Install: pip install autopep8"; \ exit 1; \ fi - @cd test/service && autopep8 --in-place --indent-size=2 --max-line-length=120 *.py + @cd test/service && autopep8 --in-place --indent-size=2 --max-line-length=120 *.py mocks/*.py @echo "✓ Python files formatted" install-hooks: @@ -111,7 +111,7 @@ lint-python: echo "autopep8 not found. Install: pip install autopep8"; \ exit 1; \ fi - @cd test/service && autopep8 --diff --exit-code --indent-size=2 --max-line-length=120 *.py && echo "✓ Python lint passed" + @cd test/service && autopep8 --diff --exit-code --indent-size=2 --max-line-length=120 *.py mocks/*.py && echo "✓ Python lint passed" lint-trivy: @echo "Running Trivy security scan..." diff --git a/tracker/conanfile.txt b/tracker/conanfile.txt index b58cc2f92..b824ba1a7 100644 --- a/tracker/conanfile.txt +++ b/tracker/conanfile.txt @@ -17,7 +17,7 @@ CMakeDeps CMakeToolchain [options] -cpp-httplib/*:with_openssl=False +cpp-httplib/*:with_openssl=True opencv/*:shared=True opencv/*:tracking=True opencv/*:video=True diff --git a/tracker/config/tracker.json b/tracker/config/tracker.json index 53fcf306a..05689e8aa 100644 --- a/tracker/config/tracker.json +++ b/tracker/config/tracker.json @@ -13,6 +13,10 @@ "healthcheck": { "port": 8080 } + }, + "manager": { + "url": "https://web.scenescape.intel.com", + "auth_path": "/run/secrets/controller.auth" } }, "observability": { @@ -29,7 +33,6 @@ "non_measurement_time_static_s": 1.6 }, "scenes": { - "source": "file", - "file_path": "scenes.json" + "source": "api" } } diff --git a/tracker/inc/config_loader.hpp b/tracker/inc/config_loader.hpp index 05cbb5fb0..5b29bc382 100644 --- a/tracker/inc/config_loader.hpp +++ b/tracker/inc/config_loader.hpp @@ -3,7 +3,7 @@ #pragma once -#include "scene_loader.hpp" +#include "scenes_config.hpp" #include #include @@ -47,12 +47,22 @@ struct TrackerConfig { bool schema_validation = true; }; +/** + * @brief Manager REST API connection settings. + */ +struct ManagerConfig { + std::string url; ///< Manager API base URL + std::string auth_path; ///< Path to JSON auth file {user, password} + std::optional ca_cert_path; ///< CA cert for HTTPS verification +}; + /** * @brief External service connections. */ struct InfrastructureConfig { MqttConfig mqtt; TrackerConfig tracker; + std::optional manager; ///< Required when scenes.source='api' }; /** @@ -134,6 +144,12 @@ constexpr char TRACKING_NON_MEASUREMENT_TIME_DYNAMIC_S[] = "/tracking/non_measurement_time_dynamic_s"; constexpr char TRACKING_NON_MEASUREMENT_TIME_STATIC_S[] = "/tracking/non_measurement_time_static_s"; +// Manager +constexpr char INFRASTRUCTURE_MANAGER[] = "/infrastructure/manager"; +constexpr char INFRASTRUCTURE_MANAGER_URL[] = "/infrastructure/manager/url"; +constexpr char INFRASTRUCTURE_MANAGER_AUTH_PATH[] = "/infrastructure/manager/auth_path"; +constexpr char INFRASTRUCTURE_MANAGER_CA_CERT_PATH[] = "/infrastructure/manager/ca_cert_path"; + // Scenes constexpr char SCENES_SOURCE[] = "/scenes/source"; constexpr char SCENES_FILE_PATH[] = "/scenes/file_path"; diff --git a/tracker/inc/env_vars.hpp b/tracker/inc/env_vars.hpp index 98a697d97..07e6f7edd 100644 --- a/tracker/inc/env_vars.hpp +++ b/tracker/inc/env_vars.hpp @@ -58,6 +58,17 @@ constexpr const char* NON_MEASUREMENT_TIME_DYNAMIC_S = "TRACKER_NON_MEASUREMENT_ /// seconds, >= 0 - RobotVision tracker parameter constexpr const char* NON_MEASUREMENT_TIME_STATIC_S = "TRACKER_NON_MEASUREMENT_TIME_STATIC_S"; +// Manager API overrides + +/// Manager API base URL +constexpr const char* MANAGER_URL = "TRACKER_MANAGER_URL"; + +/// Path to JSON auth file {user, password} +constexpr const char* MANAGER_AUTH_PATH = "TRACKER_MANAGER_AUTH_PATH"; + +/// Path to CA certificate for HTTPS verification +constexpr const char* MANAGER_CA_CERT_PATH = "TRACKER_MANAGER_CA_CERT_PATH"; + // Scenes overrides /// "file"|"api" diff --git a/tracker/inc/manager_rest_client.hpp b/tracker/inc/manager_rest_client.hpp new file mode 100644 index 000000000..c3b3b6b71 --- /dev/null +++ b/tracker/inc/manager_rest_client.hpp @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include + +namespace tracker { + +/** + * @brief Abstract interface for Manager REST API operations. + * + * Enables dependency injection and mock-based testing of components + * that depend on the Manager API (e.g., ApiSceneLoader). + */ +class IManagerRestClient { +public: + virtual ~IManagerRestClient() = default; + + /** + * @brief Authenticate with the Manager API. + * + * @param username API username + * @param password API password + * @throws std::runtime_error on connection failure, HTTP error, or auth rejection + */ + virtual void authenticate(const std::string& username, const std::string& password) = 0; + + /** + * @brief Fetch all scenes from the Manager API. + * + * @return Raw JSON response body string + * @throws std::runtime_error if not authenticated, connection fails, or HTTP error + */ + virtual std::string fetchScenes() = 0; +}; + +/** + * @brief HTTP client for Manager REST API. + * + * Handles authentication and scene fetching from the SceneScape Manager. + * Supports HTTPS with CA certificate verification. + */ +class ManagerRestClient : public IManagerRestClient { +public: + /** + * @brief Construct a Manager REST API client. + * + * @param url Manager API base URL (e.g., "https://web.scenescape.intel.com") + * @param ca_cert_path Optional CA certificate path for HTTPS verification + * @param connect_timeout TCP connect timeout (default: 10s) + * @param read_timeout HTTP read timeout (default: 30s) + */ + ManagerRestClient(std::string url, std::optional ca_cert_path = std::nullopt, + std::chrono::milliseconds connect_timeout = std::chrono::seconds(10), + std::chrono::milliseconds read_timeout = std::chrono::seconds(30)); + + void authenticate(const std::string& username, const std::string& password) override; + std::string fetchScenes() override; + +private: + std::string url_; + std::optional ca_cert_path_; + std::string token_; + std::chrono::milliseconds connect_timeout_; + std::chrono::milliseconds read_timeout_; +}; + +} // namespace tracker diff --git a/tracker/inc/message_handler.hpp b/tracker/inc/message_handler.hpp index 0d50b5e53..b196a6acc 100644 --- a/tracker/inc/message_handler.hpp +++ b/tracker/inc/message_handler.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -57,9 +58,15 @@ class MessageHandler { /// Topic pattern for scene output (format with scene_id and thing_type) static constexpr const char* TOPIC_SCENE_DATA_PATTERN = "scenescape/data/scene/{}/{}"; + /// Topic for database update notifications (dynamic mode) + static constexpr const char* TOPIC_DATABASE_UPDATE = "scenescape/cmd/database"; + /// Default thing type for output (category from detection) static constexpr const char* DEFAULT_THING_TYPE = "thing"; + /// Callback type for requesting service shutdown (dynamic scene reload) + using ShutdownCallback = std::function; + /** * @brief Construct message handler with MQTT client, scene registry, and buffer. * @@ -75,6 +82,18 @@ class MessageHandler { const TrackingConfig& tracking_config, bool schema_validation = true, const std::filesystem::path& schema_dir = "/scenescape/schema"); + /** + * @brief Enable dynamic mode for database update notifications. + * + * In dynamic mode (scenes.source=api), the handler subscribes to + * scenescape/cmd/database. On receiving an update (scene create, update, + * delete, camera change, etc.), it invokes the shutdown callback to + * trigger a graceful restart. + * + * @param callback Function to call when database update is received + */ + void enableDynamicMode(ShutdownCallback callback); + /** * @brief Start message handling (subscribe to topics). */ @@ -114,6 +133,17 @@ class MessageHandler { */ void handleCameraMessage(const std::string& topic, const std::string& payload); + /** + * @brief Handle database update notification (dynamic mode). + * + * Logs the change and triggers graceful shutdown via shutdown callback. + * Fires on any database change: scene create/update/delete, camera changes, etc. + * + * @param topic MQTT topic (scenescape/cmd/database) + * @param payload Message payload (content is logged but not parsed) + */ + void handleDatabaseUpdateMessage(const std::string& topic, const std::string& payload); + /** * @brief Extract camera_id from topic. * @@ -157,11 +187,22 @@ class MessageHandler { static std::unique_ptr loadSchema(const std::filesystem::path& schema_path); + /** + * @brief Route incoming MQTT message to the appropriate handler. + * + * Routes by topic: + * - scenescape/cmd/database -> handleDatabaseUpdateMessage + * - scenescape/data/camera/ -> handleCameraMessage (default) + */ + void routeMessage(const std::string& topic, const std::string& payload); + std::shared_ptr mqtt_client_; const SceneRegistry& scene_registry_; TimeChunkBuffer& buffer_; TrackingConfig tracking_config_; bool schema_validation_; + bool dynamic_mode_{false}; + ShutdownCallback shutdown_callback_; std::unique_ptr camera_schema_; std::unique_ptr scene_schema_; diff --git a/tracker/inc/scene_loader.hpp b/tracker/inc/scene_loader.hpp index 2edd8bada..920091a53 100644 --- a/tracker/inc/scene_loader.hpp +++ b/tracker/inc/scene_loader.hpp @@ -3,13 +3,20 @@ #pragma once +#include "config_loader.hpp" +#include "manager_rest_client.hpp" + #include #include +#include #include #include #include +#include #include +#include + namespace tracker { /** @@ -67,22 +74,6 @@ struct Scene { std::vector cameras; ///< Cameras assigned to this scene }; -/** - * @brief Scene configuration source type. - */ -enum class SceneSource { - File, ///< Load scenes from external JSON file (scenes.file_path) - Api ///< Fetch scenes from Manager REST API (not yet implemented) -}; - -/** - * @brief Scene configuration source settings. - */ -struct ScenesConfig { - SceneSource source = SceneSource::File; ///< Scene source type - std::optional file_path; ///< Path to scene file (when source=File) -}; - /** * @brief Abstract interface for loading scene configurations. * @@ -109,11 +100,30 @@ class ISceneLoader { * * @param config Scene source configuration * @param config_dir Directory containing config file (for resolving relative paths) + * @param manager_config Manager API config (required when source=Api) + * @param schema_dir Directory containing schema files (for API response validation) * @return Unique pointer to the scene loader implementation * @throws std::runtime_error if configuration is invalid */ -std::unique_ptr create_scene_loader(const ScenesConfig& config, - const std::filesystem::path& config_dir); +std::unique_ptr +create_scene_loader(const ScenesConfig& config, const std::filesystem::path& config_dir, + const std::optional& manager_config = std::nullopt, + const std::filesystem::path& schema_dir = {}); + +/// Factory type for creating IManagerRestClient instances (for testability). +using ManagerClientFactory = + std::function(const ManagerConfig&)>; + +/// Default factory: creates a real ManagerRestClient. +inline std::unique_ptr default_manager_client_factory(const ManagerConfig& c) { + return std::make_unique(c.url, c.ca_cert_path); +} + +// Internal factory functions used by create_scene_loader (defined in separate TUs) +std::unique_ptr +create_api_scene_loader(const ManagerConfig& manager_config, + const std::filesystem::path& schema_dir, + ManagerClientFactory client_factory = default_manager_client_factory); /// JSON Pointer paths (RFC6901) for scene/camera fields namespace scene_json { @@ -142,4 +152,28 @@ constexpr char CAMERA_EXTRINSICS_ROTATION[] = "/extrinsics/rotation"; constexpr char CAMERA_EXTRINSICS_SCALE[] = "/extrinsics/scale"; } // namespace scene_json +/// Internal helpers for API scene loading (exposed for testability). +namespace detail { + +/// Read file contents and trim trailing whitespace. +std::string read_file_trimmed(const std::filesystem::path& path); + +/// Transform a single camera from Manager API flat format to tracker schema format. +void transform_camera_to_schema(rapidjson::Value& camera, + rapidjson::Document::AllocatorType& alloc); + +/// Transform API response scenes array to tracker schema format. +void transform_api_scenes(rapidjson::Document& doc); + +/// Read username and password from JSON auth file. +std::pair read_auth_file(const std::string& path); + +/// Validate each scene in the array against a JSON schema file. +/// Returns a new document containing only the scenes that passed validation. +/// Invalid scenes are logged with a warning and skipped. +rapidjson::Document validate_scenes(const rapidjson::Document& scenes_doc, + const std::filesystem::path& schema_path); + +} // namespace detail + } // namespace tracker diff --git a/tracker/inc/scene_parser.hpp b/tracker/inc/scene_parser.hpp new file mode 100644 index 000000000..1178d220b --- /dev/null +++ b/tracker/inc/scene_parser.hpp @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "scene_loader.hpp" + +#include "json_utils.hpp" + +#include +#include +#include + +#include +#include + +namespace tracker { +namespace detail { + +using Pointer = rapidjson::Pointer; + +/** + * @brief Get required JSON array by pointer. + */ +inline const rapidjson::Value::ConstArray +require_array(const rapidjson::Value& doc, const char* pointer, const std::string& context) { + if (auto* val = Pointer(pointer).Get(doc)) { + if (val->IsArray()) { + return val->GetArray(); + } + } + throw std::runtime_error("Missing required " + context + " array: " + pointer); +} + +/** + * @brief Get required 3-element array from JSON. + */ +inline std::array require_array3(const rapidjson::Value& doc, const char* pointer, + const std::string& context) { + if (auto* val = Pointer(pointer).Get(doc)) { + if (val->IsArray() && val->Size() == 3) { + std::array result; + for (size_t i = 0; i < 3; ++i) { + if (!(*val)[i].IsNumber()) { + throw std::runtime_error(context + ": " + pointer + "[" + std::to_string(i) + + "] must be a number"); + } + result[i] = (*val)[i].GetDouble(); + } + return result; + } + } + throw std::runtime_error("Missing required " + context + " array: " + pointer); +} + +/** + * @brief Parse a single scene JSON value into a Scene struct. + * + * Expects the canonical schema format (intrinsics nested, extrinsics nested). + * Used by both FileSceneLoader and ApiSceneLoader. + * See scene.schema.json. + */ +inline Scene parse_scene(const rapidjson::Value& scene_val) { + Scene scene; + scene.uid = require_value(scene_val, scene_json::SCENE_UID, "scene"); + scene.name = require_value(scene_val, scene_json::SCENE_NAME, "scene"); + + for (const auto& cam_val : require_array(scene_val, scene_json::SCENE_CAMERAS, "scene")) { + Camera camera; + camera.uid = require_value(cam_val, scene_json::CAMERA_UID, "camera"); + camera.name = require_value(cam_val, scene_json::CAMERA_NAME, "camera"); + + // Parse intrinsics (optional, default to 0.0) + camera.intrinsics.fx = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_FX).value_or(0.0); + camera.intrinsics.fy = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_FY).value_or(0.0); + camera.intrinsics.cx = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_CX).value_or(0.0); + camera.intrinsics.cy = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_CY).value_or(0.0); + + // Parse distortion (optional, default to 0.0) + camera.intrinsics.distortion.k1 = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_K1).value_or(0.0); + camera.intrinsics.distortion.k2 = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_K2).value_or(0.0); + camera.intrinsics.distortion.p1 = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_P1).value_or(0.0); + camera.intrinsics.distortion.p2 = + get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_P2).value_or(0.0); + + // Parse extrinsics (required) + std::string cam_context = "camera '" + camera.uid + "'"; + camera.extrinsics.translation = + require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_TRANSLATION, cam_context); + camera.extrinsics.rotation = + require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_ROTATION, cam_context); + camera.extrinsics.scale = + require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_SCALE, cam_context); + + scene.cameras.push_back(std::move(camera)); + } + + return scene; +} + +} // namespace detail +} // namespace tracker diff --git a/tracker/inc/scene_registry.hpp b/tracker/inc/scene_registry.hpp index 585dda4fb..d127eae4b 100644 --- a/tracker/inc/scene_registry.hpp +++ b/tracker/inc/scene_registry.hpp @@ -3,7 +3,7 @@ #pragma once -#include "config_loader.hpp" +#include "scene_loader.hpp" #include #include diff --git a/tracker/inc/scenes_config.hpp b/tracker/inc/scenes_config.hpp new file mode 100644 index 000000000..0b13c5193 --- /dev/null +++ b/tracker/inc/scenes_config.hpp @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +namespace tracker { + +/** + * @brief Scene configuration source type. + */ +enum class SceneSource { + File, ///< Load scenes from external JSON file (scenes.file_path) + Api ///< Fetch scenes from Manager REST API +}; + +/** + * @brief Scene configuration source settings. + */ +struct ScenesConfig { + SceneSource source = SceneSource::Api; ///< Scene source type + std::optional file_path; ///< Path to scene file (when source=File) +}; + +} // namespace tracker diff --git a/tracker/schema/config.schema.json b/tracker/schema/config.schema.json index ff71e4c55..af455d834 100644 --- a/tracker/schema/config.schema.json +++ b/tracker/schema/config.schema.json @@ -19,8 +19,7 @@ }, "manager": { "url": "https://manager.scenescape.intel.com", - "username_path": "/run/secrets/manager-username", - "password_path": "/run/secrets/manager-password" + "auth_path": "/run/secrets/manager.auth" } } }, @@ -89,22 +88,22 @@ }, "manager": { "type": "object", - "description": "Manager REST API connection for fetching scene configurations. Required when scenes.source is 'api' or omitted.", + "description": "Manager REST API connection for fetching scene configurations. Required when scenes.source is 'api'.", "properties": { "url": { "type": "string", "description": "Manager API base URL" }, - "username_path": { + "auth_path": { "type": "string", - "description": "Path to file containing API username" + "description": "Path to JSON auth file containing user and password keys" }, - "password_path": { + "ca_cert_path": { "type": "string", - "description": "Path to file containing API password" + "description": "Path to CA certificate for HTTPS verification." } }, - "required": ["url", "username_path", "password_path"] + "required": ["url", "auth_path"] }, "otlp": { "type": "object", @@ -239,13 +238,13 @@ }, "scenes": { "type": "object", - "description": "Scene configuration source. Use 'file' to load scenes from an external JSON file (default). Use 'api' to fetch scenes dynamically from Manager REST API (requires infrastructure.manager, not yet implemented).", + "description": "Scene configuration source. Use 'api' (default) to fetch scenes dynamically from Manager REST API (requires infrastructure.manager). Use 'file' to load scenes from an external JSON file.", "properties": { "source": { "type": "string", "description": "Configuration source: file (from JSON file) or api (from Manager REST API)", "enum": ["file", "api"], - "default": "file" + "default": "api" }, "file_path": { "type": "string", diff --git a/tracker/src/api_scene_loader.cpp b/tracker/src/api_scene_loader.cpp new file mode 100644 index 000000000..ed89be4d6 --- /dev/null +++ b/tracker/src/api_scene_loader.cpp @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "scene_loader.hpp" + +#include "config_loader.hpp" +#include "logger.hpp" +#include "scene_parser.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace tracker { +namespace detail { + +std::string read_file_trimmed(const std::filesystem::path& path) { + std::ifstream ifs(path); + if (!ifs.is_open()) { + throw std::runtime_error("Failed to open file: " + path.string()); + } + std::string content((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + auto end = content.find_last_not_of(" \t\n\r"); + if (end != std::string::npos) { + content.erase(end + 1); + } + return content; +} + +void transform_camera_to_schema(rapidjson::Value& camera, + rapidjson::Document::AllocatorType& alloc) { + // Build extrinsics object from flat camera fields + if (!camera.HasMember("extrinsics")) { + rapidjson::Value extrinsics(rapidjson::kObjectType); + + if (camera.HasMember("translation") && camera["translation"].IsArray()) { + rapidjson::Value arr(camera["translation"], alloc); + extrinsics.AddMember("translation", arr, alloc); + } + if (camera.HasMember("rotation") && camera["rotation"].IsArray()) { + rapidjson::Value arr(camera["rotation"], alloc); + extrinsics.AddMember("rotation", arr, alloc); + } + if (camera.HasMember("scale") && camera["scale"].IsArray()) { + rapidjson::Value arr(camera["scale"], alloc); + extrinsics.AddMember("scale", arr, alloc); + } + + camera.AddMember("extrinsics", extrinsics, alloc); + } + + // Move distortion inside intrinsics if it's at camera root level + if (camera.HasMember("distortion") && !camera.HasMember("intrinsics")) { + rapidjson::Value intrinsics(rapidjson::kObjectType); + rapidjson::Value dist(camera["distortion"], alloc); + intrinsics.AddMember("distortion", dist, alloc); + camera.AddMember("intrinsics", intrinsics, alloc); + } else if (camera.HasMember("distortion") && camera.HasMember("intrinsics")) { + if (!camera["intrinsics"].HasMember("distortion")) { + rapidjson::Value dist(camera["distortion"], alloc); + camera["intrinsics"].AddMember("distortion", dist, alloc); + } + } +} + +void transform_api_scenes(rapidjson::Document& doc) { + auto& alloc = doc.GetAllocator(); + + if (!doc.IsArray()) + return; + + for (auto& scene_val : doc.GetArray()) { + if (scene_val.HasMember("cameras") && scene_val["cameras"].IsArray()) { + for (auto& cam_val : scene_val["cameras"].GetArray()) { + transform_camera_to_schema(cam_val, alloc); + } + } + } +} + +std::pair read_auth_file(const std::string& path) { + std::string content = read_file_trimmed(path); + + rapidjson::Document doc; + doc.Parse(content.c_str()); + + if (doc.HasParseError() || !doc.IsObject()) { + throw std::runtime_error("Auth file is not valid JSON: " + path); + } + + if (!doc.HasMember("user") || !doc["user"].IsString()) { + throw std::runtime_error("Auth file missing 'user' field: " + path); + } + if (!doc.HasMember("password") || !doc["password"].IsString()) { + throw std::runtime_error("Auth file missing 'password' field: " + path); + } + + return {doc["user"].GetString(), doc["password"].GetString()}; +} + +rapidjson::Document validate_scenes(const rapidjson::Document& scenes_doc, + const std::filesystem::path& schema_path) { + if (!scenes_doc.IsArray()) { + throw std::runtime_error("validate_scenes: input must be a JSON array"); + } + + std::ifstream ifs(schema_path); + if (!ifs.is_open()) { + throw std::runtime_error("Failed to open scene schema: " + schema_path.string()); + } + + rapidjson::IStreamWrapper isw(ifs); + rapidjson::Document schema_doc; + schema_doc.ParseStream(isw); + if (schema_doc.HasParseError()) { + throw std::runtime_error("Failed to parse scene schema: " + schema_path.string()); + } + + rapidjson::SchemaDocument schema(schema_doc); + + rapidjson::Document valid_scenes; + valid_scenes.SetArray(); + auto& alloc = valid_scenes.GetAllocator(); + + int scene_index = 0; + for (const auto& scene_val : scenes_doc.GetArray()) { + rapidjson::SchemaValidator validator(schema); + if (!scene_val.Accept(validator)) { + rapidjson::StringBuffer sb; + validator.GetInvalidSchemaPointer().StringifyUriFragment(sb); + + std::string scene_id = "index " + std::to_string(scene_index); + if (scene_val.HasMember("name") && scene_val["name"].IsString()) { + scene_id = "'" + std::string(scene_val["name"].GetString()) + "'"; + } + + LOG_WARN("Skipping scene {} — validation failed at: {}, keyword: {}", scene_id, + sb.GetString(), validator.GetInvalidSchemaKeyword()); + } else { + rapidjson::Value scene_copy(scene_val, alloc); + valid_scenes.PushBack(scene_copy, alloc); + } + ++scene_index; + } + + return valid_scenes; +} + +} // namespace detail + +namespace { + +class ApiSceneLoader : public ISceneLoader { +public: + ApiSceneLoader(ManagerConfig manager_config, std::filesystem::path schema_dir, + ManagerClientFactory client_factory) + : manager_config_(std::move(manager_config)), schema_dir_(std::move(schema_dir)), + client_factory_(std::move(client_factory)) {} + + std::vector load() override { + // Read credentials from auth file + auto [username, password] = detail::read_auth_file(manager_config_.auth_path); + + // Authenticate and fetch scenes + auto client = client_factory_(manager_config_); + client->authenticate(username, password); + std::string response_body = client->fetchScenes(); + + // Parse the API response + rapidjson::Document response_doc; + response_doc.Parse(response_body.c_str()); + if (response_doc.HasParseError()) { + throw std::runtime_error("Failed to parse Manager API response at offset " + + std::to_string(response_doc.GetErrorOffset())); + } + + if (!response_doc.IsObject() || !response_doc.HasMember("results") || + !response_doc["results"].IsArray()) { + throw std::runtime_error("Manager API response missing 'results' array"); + } + + // Extract results array into a new document for transformation + rapidjson::Document scenes_doc; + scenes_doc.SetArray(); + auto& alloc = scenes_doc.GetAllocator(); + for (const auto& scene_val : response_doc["results"].GetArray()) { + rapidjson::Value scene_copy(scene_val, alloc); + scenes_doc.PushBack(scene_copy, alloc); + } + + // Transform flat API format -> nested schema format + detail::transform_api_scenes(scenes_doc); + + // Validate each scene against scene.schema.json (skips invalid scenes with warning) + auto scene_schema_path = schema_dir_ / "scene.schema.json"; + auto valid_scenes = detail::validate_scenes(scenes_doc, scene_schema_path); + + // Parse validated scenes into structs + std::vector scenes; + for (const auto& scene_val : valid_scenes.GetArray()) { + scenes.push_back(detail::parse_scene(scene_val)); + } + + LOG_INFO("Loaded {} scenes from Manager API", scenes.size()); + return scenes; + } + +private: + ManagerConfig manager_config_; + std::filesystem::path schema_dir_; + ManagerClientFactory client_factory_; +}; + +} // namespace + +std::unique_ptr create_api_scene_loader(const ManagerConfig& manager_config, + const std::filesystem::path& schema_dir, + ManagerClientFactory client_factory) { + return std::make_unique(manager_config, schema_dir, std::move(client_factory)); +} + +} // namespace tracker diff --git a/tracker/src/config_loader.cpp b/tracker/src/config_loader.cpp index 52783cdf5..33723092f 100644 --- a/tracker/src/config_loader.cpp +++ b/tracker/src/config_loader.cpp @@ -215,7 +215,7 @@ ServiceConfig load_config(const std::filesystem::path& config_path, // Scenes configuration (required) - parse source and file_path only // Actual scene loading is done via ISceneLoader in main std::string source_str = - GetValueByPointerWithDefault(config_doc, json::SCENES_SOURCE, "file").GetString(); + GetValueByPointerWithDefault(config_doc, json::SCENES_SOURCE, "api").GetString(); if (source_str == "file") { config.scenes.source = SceneSource::File; @@ -236,6 +236,27 @@ ServiceConfig load_config(const std::filesystem::path& config_path, } } + // Infrastructure - Manager (required when scenes.source='api') + if (GetValueByPointer(config_doc, json::INFRASTRUCTURE_MANAGER)) { + ManagerConfig manager_config; + if (auto* url = GetValueByPointer(config_doc, json::INFRASTRUCTURE_MANAGER_URL)) { + manager_config.url = url->GetString(); + } else { + throw std::runtime_error("Missing required config: " + + std::string(json::INFRASTRUCTURE_MANAGER_URL)); + } + if (auto* auth = GetValueByPointer(config_doc, json::INFRASTRUCTURE_MANAGER_AUTH_PATH)) { + manager_config.auth_path = auth->GetString(); + } else { + throw std::runtime_error("Missing required config: " + + std::string(json::INFRASTRUCTURE_MANAGER_AUTH_PATH)); + } + if (auto* ca = GetValueByPointer(config_doc, json::INFRASTRUCTURE_MANAGER_CA_CERT_PATH)) { + manager_config.ca_cert_path = std::string(ca->GetString()); + } + config.infrastructure.manager = manager_config; + } + // Tracking configuration (optional - defaults from constants in config_loader.hpp) config.tracking.max_lag_s = GetValueByPointerWithDefault(config_doc, json::TRACKING_MAX_LAG_S, kDefaultMaxLagS) @@ -349,6 +370,37 @@ ServiceConfig load_config(const std::filesystem::path& config_path, config.scenes.file_path = val.value(); } + // Manager env var overrides + auto env_mgr_url = get_env(tracker::env::MANAGER_URL); + auto env_mgr_auth = get_env(tracker::env::MANAGER_AUTH_PATH); + auto env_mgr_ca = get_env(tracker::env::MANAGER_CA_CERT_PATH); + if (env_mgr_url.has_value() || env_mgr_auth.has_value() || env_mgr_ca.has_value()) { + if (!config.infrastructure.manager.has_value()) { + config.infrastructure.manager = ManagerConfig{}; + } + auto& mgr = config.infrastructure.manager.value(); + if (env_mgr_url.has_value()) + mgr.url = env_mgr_url.value(); + if (env_mgr_auth.has_value()) + mgr.auth_path = env_mgr_auth.value(); + if (env_mgr_ca.has_value()) + mgr.ca_cert_path = env_mgr_ca.value(); + } + + // Re-validate after env overrides: API mode requires manager config + if (config.scenes.source == SceneSource::Api) { + if (!config.infrastructure.manager.has_value()) { + throw std::runtime_error("Missing required config: infrastructure.manager (required " + "when scenes.source='api')"); + } + const auto& mgr = config.infrastructure.manager.value(); + if (mgr.url.empty() || mgr.auth_path.empty()) { + throw std::runtime_error( + "Invalid infrastructure.manager configuration: url and auth_path must be set " + "and non-empty when scenes.source='api'"); + } + } + // TLS overrides - create tls config if any TLS env var is set auto env_tls_ca = get_env(tracker::env::MQTT_TLS_CA_CERT); auto env_tls_cert = get_env(tracker::env::MQTT_TLS_CLIENT_CERT); diff --git a/tracker/src/main.cpp b/tracker/src/main.cpp index 7f45dbe1c..a43bf88c5 100644 --- a/tracker/src/main.cpp +++ b/tracker/src/main.cpp @@ -22,14 +22,24 @@ #include "time_chunk_scheduler.hpp" #include "track_publisher.hpp" +/// Exit code for scene update restart (non-zero triggers Docker restart: on-failure) +constexpr int EXIT_SCENE_UPDATE_RESTART = 99; + +/// Shutdown reason codes for g_shutdown_requested +enum ShutdownReason : int { + RUNNING = 0, ///< Service is running normally + SIGNAL = 1, ///< SIGTERM/SIGINT received + SCENE_UPDATE = 2, ///< Scene config changed, restart to reload +}; + namespace { -volatile std::sig_atomic_t g_shutdown_requested = 0; +volatile std::sig_atomic_t g_shutdown_requested = ShutdownReason::RUNNING; std::atomic g_liveness{false}; std::atomic g_readiness{false}; std::shared_ptr g_mqtt_client; void signal_handler(int signal) { - g_shutdown_requested = 1; + g_shutdown_requested = ShutdownReason::SIGNAL; } void update_readiness() { @@ -80,8 +90,9 @@ int main(int argc, char* argv[]) { // Load scenes using appropriate loader based on config std::vector scenes; try { - auto scene_loader = - tracker::create_scene_loader(config.scenes, cli_config.config_path.parent_path()); + auto scene_loader = tracker::create_scene_loader( + config.scenes, cli_config.config_path.parent_path(), config.infrastructure.manager, + cli_config.schema_path.parent_path()); scenes = scene_loader->load(); } catch (const std::exception& e) { LOG_ERROR("Failed to load scenes: {}", e.what()); @@ -130,6 +141,15 @@ int main(int argc, char* argv[]) { g_mqtt_client, scene_registry, chunk_buffer, config.tracking, config.infrastructure.tracker.schema_validation, cli_config.schema_path.parent_path()); + // In dynamic mode (API source), enable database update notifications. + // On receiving any database change (scene create/update/delete, camera change, etc.), + // the handler triggers graceful shutdown. Docker restart policy restarts the service, + // which re-fetches all scenes from the API. + if (config.scenes.source == tracker::SceneSource::Api) { + message_handler->enableDynamicMode( + []() { g_shutdown_requested = ShutdownReason::SCENE_UPDATE; }); + } + // Connect to MQTT broker. // Sync failures (broker unreachable) throw immediately. // Async failures (auth, protocol) set exitCode() and are caught in the main loop. @@ -155,11 +175,16 @@ int main(int argc, char* argv[]) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } - // Determine exit code: async connect failure or graceful shutdown + // Determine exit code: async connect failure, database update restart, or graceful shutdown int exit_code = 0; if (g_mqtt_client->exitCode() >= 0) { exit_code = g_mqtt_client->exitCode(); LOG_ERROR("MQTT connect failure — exiting with code {}", exit_code); + } else if (g_shutdown_requested == ShutdownReason::SCENE_UPDATE) { + exit_code = EXIT_SCENE_UPDATE_RESTART; + LOG_INFO( + "Tracker service shutting down gracefully for database update restart (exit code {})", + exit_code); } else { LOG_INFO("Tracker service shutting down gracefully"); } diff --git a/tracker/src/manager_rest_client.cpp b/tracker/src/manager_rest_client.cpp new file mode 100644 index 000000000..77d5cadba --- /dev/null +++ b/tracker/src/manager_rest_client.cpp @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "manager_rest_client.hpp" + +#include "logger.hpp" + +#include + +#include +#include + +namespace tracker { + +namespace { + +/** + * @brief Parse scheme, host, and port from a URL string. + * + * Supports http:// and https:// schemes. If no port is specified, + * defaults to 80 for http and 443 for https. + * + * @return Tuple of (scheme_host_port, path_prefix) + * scheme_host_port: "https://host:port" or "http://host:port" + * path_prefix: any path after the host:port (e.g., "" or "/api") + */ +std::pair parse_url(const std::string& url) { + // Find scheme + auto scheme_end = url.find("://"); + if (scheme_end == std::string::npos) { + throw std::runtime_error("Invalid Manager URL (missing scheme): " + url); + } + + std::string scheme = url.substr(0, scheme_end); + if (scheme != "http" && scheme != "https") { + throw std::runtime_error("Invalid Manager URL scheme (must be http or https): " + url); + } + + // Find host:port and path + auto host_start = scheme_end + 3; + auto path_start = url.find('/', host_start); + + std::string scheme_host_port; + std::string path_prefix; + + if (path_start == std::string::npos) { + scheme_host_port = url; + path_prefix = ""; + } else { + scheme_host_port = url.substr(0, path_start); + path_prefix = url.substr(path_start); + // Remove trailing slash from path prefix + if (!path_prefix.empty() && path_prefix.back() == '/') { + path_prefix.pop_back(); + } + } + + return {scheme_host_port, path_prefix}; +} + +} // namespace + +ManagerRestClient::ManagerRestClient(std::string url, std::optional ca_cert_path, + std::chrono::milliseconds connect_timeout, + std::chrono::milliseconds read_timeout) + : url_(std::move(url)), ca_cert_path_(std::move(ca_cert_path)), + connect_timeout_(connect_timeout), read_timeout_(read_timeout) {} + +namespace { + +/** + * @brief Create and configure an httplib::Client for the given URL. + * + * httplib::Client handles both HTTP and HTTPS when compiled with OpenSSL. + */ +httplib::Client create_http_client(const std::string& scheme_host_port, + const std::optional& ca_cert_path, + std::chrono::milliseconds connect_timeout, + std::chrono::milliseconds read_timeout) { + httplib::Client client(scheme_host_port); + client.set_connection_timeout(connect_timeout); + client.set_read_timeout(read_timeout); + + if (scheme_host_port.starts_with("https://")) { + if (ca_cert_path.has_value() && !ca_cert_path->empty()) { + client.set_ca_cert_path(ca_cert_path->c_str()); + } + client.enable_server_certificate_verification(true); + } + + return client; +} + +} // namespace + +void ManagerRestClient::authenticate(const std::string& username, const std::string& password) { + auto [scheme_host_port, path_prefix] = parse_url(url_); + auto client = + create_http_client(scheme_host_port, ca_cert_path_, connect_timeout_, read_timeout_); + + // POST /api/v1/auth with form data + std::string auth_path = path_prefix + "/api/v1/auth"; + httplib::Params params{{"username", username}, {"password", password}}; + + LOG_DEBUG("Authenticating with Manager API at {}{}", scheme_host_port, auth_path); + + auto result = client.Post(auth_path, params); + + if (!result) { + throw std::runtime_error("Manager API connection failed: " + + httplib::to_string(result.error())); + } + + if (result->status != 200) { + throw std::runtime_error("Manager API authentication failed (HTTP " + + std::to_string(result->status) + "): " + result->body); + } + + // Parse token from response JSON: {"token": "..."} + rapidjson::Document doc; + doc.Parse(result->body.c_str()); + + if (doc.HasParseError() || !doc.IsObject()) { + throw std::runtime_error("Manager API auth response is not valid JSON"); + } + + if (!doc.HasMember("token") || !doc["token"].IsString()) { + throw std::runtime_error("Manager API auth response missing 'token' field"); + } + + token_ = doc["token"].GetString(); + LOG_INFO("Authenticated with Manager API successfully"); +} + +std::string ManagerRestClient::fetchScenes() { + if (token_.empty()) { + throw std::runtime_error("Manager API not authenticated — call authenticate() first"); + } + + auto [scheme_host_port, path_prefix] = parse_url(url_); + auto client = + create_http_client(scheme_host_port, ca_cert_path_, connect_timeout_, read_timeout_); + + // GET /api/v1/scenes with auth header + std::string scenes_path = path_prefix + "/api/v1/scenes"; + httplib::Headers headers = {{"Authorization", "Token " + token_}}; + + LOG_DEBUG("Fetching scenes from Manager API: {}{}", scheme_host_port, scenes_path); + + auto result = client.Get(scenes_path, headers); + + if (!result) { + throw std::runtime_error("Manager API connection failed: " + + httplib::to_string(result.error())); + } + + if (result->status != 200) { + throw std::runtime_error("Manager API scenes request failed (HTTP " + + std::to_string(result->status) + "): " + result->body); + } + + LOG_INFO("Fetched scenes from Manager API ({} bytes)", result->body.size()); + return result->body; +} + +} // namespace tracker diff --git a/tracker/src/message_handler.cpp b/tracker/src/message_handler.cpp index 3dd3cebbe..b3c4d08c6 100644 --- a/tracker/src/message_handler.cpp +++ b/tracker/src/message_handler.cpp @@ -90,10 +90,17 @@ MessageHandler::loadSchema(const std::filesystem::path& schema_path) { return std::make_unique(schema_doc); } +void MessageHandler::enableDynamicMode(ShutdownCallback callback) { + dynamic_mode_ = true; + shutdown_callback_ = std::move(callback); + LOG_INFO_ENTRY(LogEntry("Dynamic mode enabled - will subscribe to database update topic") + .component("mqtt")); +} + void MessageHandler::start() { - // Set up message callback + // Set up message callback with topic-based routing mqtt_client_->setMessageCallback([this](const std::string& topic, const std::string& payload) { - handleCameraMessage(topic, payload); + routeMessage(topic, payload); }); // Subscribe to each registered camera's topic @@ -124,6 +131,14 @@ void MessageHandler::start() { LOG_INFO_ENTRY(LogEntry("Queued camera subscriptions") .component("mqtt") .operation(std::format("{} cameras", camera_ids.size()))); + + // In dynamic mode, subscribe to database update topic for config change notifications + if (dynamic_mode_) { + mqtt_client_->subscribe(TOPIC_DATABASE_UPDATE); + LOG_INFO_ENTRY(LogEntry("Queued database update subscription") + .component("mqtt") + .operation(TOPIC_DATABASE_UPDATE)); + } } void MessageHandler::stop() { @@ -140,9 +155,36 @@ void MessageHandler::stop() { auto topic = std::format(TOPIC_CAMERA_SUBSCRIBE_PATTERN, camera_id); mqtt_client_->unsubscribe(topic); } + + // Unsubscribe from database update topic (dynamic mode) + if (dynamic_mode_) { + mqtt_client_->unsubscribe(TOPIC_DATABASE_UPDATE); + } + mqtt_client_->setMessageCallback(nullptr); } +void MessageHandler::routeMessage(const std::string& topic, const std::string& payload) { + if (dynamic_mode_ && topic == TOPIC_DATABASE_UPDATE) { + handleDatabaseUpdateMessage(topic, payload); + } else { + handleCameraMessage(topic, payload); + } +} + +void MessageHandler::handleDatabaseUpdateMessage(const std::string& topic, + const std::string& payload) { + LOG_INFO_ENTRY(LogEntry("Database update received, triggering restart") + .component("message_handler") + .mqtt({.topic = topic, .direction = "subscribe"})); + + if (shutdown_callback_) { + shutdown_callback_(); + } else { + LOG_WARN("Database update received but no shutdown callback registered"); + } +} + void MessageHandler::handleCameraMessage(const std::string& topic, const std::string& payload) { received_count_++; diff --git a/tracker/src/scene_loader.cpp b/tracker/src/scene_loader.cpp index 0c125f723..909cbeaa7 100644 --- a/tracker/src/scene_loader.cpp +++ b/tracker/src/scene_loader.cpp @@ -3,52 +3,18 @@ #include "scene_loader.hpp" -#include "json_utils.hpp" +#include "scene_parser.hpp" -#include #include #include #include #include -#include namespace tracker { namespace { -using Pointer = rapidjson::Pointer; -using detail::get_value; -using detail::require_value; - -const rapidjson::Value::ConstArray require_array(const rapidjson::Value& doc, const char* pointer, - const std::string& context) { - if (auto* val = Pointer(pointer).Get(doc)) { - if (val->IsArray()) { - return val->GetArray(); - } - } - throw std::runtime_error("Missing required " + context + " array: " + pointer); -} - -std::array require_array3(const rapidjson::Value& doc, const char* pointer, - const std::string& context) { - if (auto* val = Pointer(pointer).Get(doc)) { - if (val->IsArray() && val->Size() == 3) { - std::array result; - for (size_t i = 0; i < 3; ++i) { - if (!(*val)[i].IsNumber()) { - throw std::runtime_error(context + ": " + pointer + "[" + std::to_string(i) + - "] must be a number"); - } - result[i] = (*val)[i].GetDouble(); - } - return result; - } - } - throw std::runtime_error("Missing required " + context + " array: " + pointer); -} - class FileSceneLoader : public ISceneLoader { public: explicit FileSceneLoader(std::filesystem::path file_path) : file_path_(std::move(file_path)) {} @@ -75,54 +41,7 @@ class FileSceneLoader : public ISceneLoader { std::vector scenes; for (const auto& scene_val : doc.GetArray()) { - Scene scene; - scene.uid = require_value(scene_val, scene_json::SCENE_UID, "scene"); - scene.name = require_value(scene_val, scene_json::SCENE_NAME, "scene"); - - for (const auto& cam_val : - require_array(scene_val, scene_json::SCENE_CAMERAS, "scene")) { - Camera camera; - camera.uid = require_value(cam_val, scene_json::CAMERA_UID, "camera"); - camera.name = - require_value(cam_val, scene_json::CAMERA_NAME, "camera"); - - // Parse intrinsics (optional, default to 0.0) - camera.intrinsics.fx = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_FX).value_or(0.0); - camera.intrinsics.fy = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_FY).value_or(0.0); - camera.intrinsics.cx = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_CX).value_or(0.0); - camera.intrinsics.cy = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_CY).value_or(0.0); - - // Parse distortion (optional, default to 0.0) - camera.intrinsics.distortion.k1 = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_K1) - .value_or(0.0); - camera.intrinsics.distortion.k2 = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_K2) - .value_or(0.0); - camera.intrinsics.distortion.p1 = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_P1) - .value_or(0.0); - camera.intrinsics.distortion.p2 = - get_value(cam_val, scene_json::CAMERA_INTRINSICS_DISTORTION_P2) - .value_or(0.0); - - // Parse extrinsics (required) - std::string cam_context = "camera '" + camera.uid + "'"; - camera.extrinsics.translation = - require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_TRANSLATION, cam_context); - camera.extrinsics.rotation = - require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_ROTATION, cam_context); - camera.extrinsics.scale = - require_array3(cam_val, scene_json::CAMERA_EXTRINSICS_SCALE, cam_context); - - scene.cameras.push_back(std::move(camera)); - } - - scenes.push_back(std::move(scene)); + scenes.push_back(detail::parse_scene(scene_val)); } return scenes; @@ -132,17 +51,12 @@ class FileSceneLoader : public ISceneLoader { std::filesystem::path file_path_; }; -class ApiSceneLoader : public ISceneLoader { -public: - std::vector load() override { - throw std::runtime_error("API scene loading is not yet implemented"); - } -}; - } // namespace -std::unique_ptr create_scene_loader(const ScenesConfig& config, - const std::filesystem::path& config_dir) { +std::unique_ptr +create_scene_loader(const ScenesConfig& config, const std::filesystem::path& config_dir, + const std::optional& manager_config, + const std::filesystem::path& schema_dir) { switch (config.source) { case SceneSource::File: { if (!config.file_path.has_value()) { @@ -158,8 +72,17 @@ std::unique_ptr create_scene_loader(const ScenesConfig& config, return std::make_unique(scene_file_path); } - case SceneSource::Api: - return std::make_unique(); + case SceneSource::Api: { + if (!manager_config.has_value()) { + throw std::runtime_error("Manager config is required when scenes.source='api'"); + } + if (schema_dir.empty()) { + throw std::runtime_error( + "Missing required config: scenes.schema_dir (required when " + "scenes.source='api')"); + } + return create_api_scene_loader(*manager_config, schema_dir); + } } throw std::runtime_error("Unknown scene source type"); diff --git a/tracker/test/service/conftest.py b/tracker/test/service/conftest.py index ced9fabb5..ad03b49a4 100644 --- a/tracker/test/service/conftest.py +++ b/tracker/test/service/conftest.py @@ -14,7 +14,7 @@ from waiting import wait from utils.certs import generate_test_certificates -from utils.docker import is_tracker_ready +from utils.docker import is_tracker_ready, get_container_logs, wait_for_readiness @pytest.fixture(scope="function") @@ -55,6 +55,7 @@ def tracker_service(tls_certs): f"TLS_CLIENT_CERT_FILE={tls_certs.client.cert_path}\n" f"TLS_CLIENT_KEY_FILE={tls_certs.client.key_path}\n" f"TRACKER_MQTT_INSECURE=true\n" + f"TRACKER_SCENES_SOURCE=file\n" ) docker = DockerClient( @@ -100,6 +101,7 @@ def tracker_service_delayed_broker(tls_certs): f"TLS_CLIENT_CERT_FILE={tls_certs.client.cert_path}\n" f"TLS_CLIENT_KEY_FILE={tls_certs.client.key_path}\n" f"TRACKER_MQTT_INSECURE=true\n" + f"TRACKER_SCENES_SOURCE=file\n" ) docker = DockerClient( @@ -131,3 +133,63 @@ def tracker_container_exists(): finally: print(f"\nCleaning up: {project_name}") docker.compose.down(remove_orphans=True, volumes=True) + + +@pytest.fixture(scope="function") +def tracker_service_api(tls_certs): + """ + Fixture that starts tracker with mock Manager API for dynamic scene loading. + + Uses the 'api' compose profile to activate mock-manager service and + env var overrides to reconfigure tracker for API source mode. + Tests the full API loading path: + auth file -> POST /api/v1/auth -> GET /api/v1/scenes -> transform -> validate -> parse. + + Yields: + dict: Contains 'docker' client + """ + service_dir = Path(__file__).parent + compose_file = service_dir / "docker-compose.yaml" + + project_name = f"tracker-api-{uuid.uuid4().hex[:8]}" + + env_file = tls_certs.temp_dir / ".env" + env_file.write_text( + f"TLS_CA_CERT_FILE={tls_certs.ca.cert_path}\n" + f"TLS_SERVER_CERT_FILE={tls_certs.server.cert_path}\n" + f"TLS_SERVER_KEY_FILE={tls_certs.server.key_path}\n" + f"TLS_CLIENT_CERT_FILE={tls_certs.client.cert_path}\n" + f"TLS_CLIENT_KEY_FILE={tls_certs.client.key_path}\n" + f"TRACKER_SCENES_SOURCE=api\n" + f"TRACKER_MANAGER_URL=http://mock-manager:8000\n" + f"TRACKER_MANAGER_AUTH_PATH=/run/secrets/mock-auth\n" + ) + + docker = DockerClient( + compose_files=[compose_file], + compose_project_name=project_name, + compose_project_directory=str(service_dir), + compose_env_files=[str(env_file)], + compose_profiles=["api"], + ) + + try: + print(f"\nStarting API test environment: {project_name}") + docker.compose.up(services=["mock-manager"], detach=True, wait=True) + docker.compose.up(detach=True, wait=False) + + try: + wait_for_readiness(docker, timeout=30) + except Exception: + print("\nTracker failed to become ready in API mode. Logs:") + print("--- Tracker logs ---") + print(get_container_logs(docker, "tracker")) + print("--- Mock Manager logs ---") + print(get_container_logs(docker, "mock-manager")) + raise + + yield {"docker": docker} + + finally: + print(f"\nCleaning up: {project_name}") + docker.compose.down(remove_orphans=True, volumes=True) diff --git a/tracker/test/service/docker-compose.yaml b/tracker/test/service/docker-compose.yaml index db9bcd4c7..b25ef410d 100644 --- a/tracker/test/service/docker-compose.yaml +++ b/tracker/test/service/docker-compose.yaml @@ -25,6 +25,29 @@ services: target: /mosquitto/config/mosquitto.conf restart: no + mock-manager: + profiles: ["api"] + image: python:3.12-slim + command: ["python", "/app/mock_manager.py"] + configs: + - source: mock-manager-script + target: /app/mock_manager.py + - source: mock-scenes + target: /data/scenes.json + restart: no + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')", + ] + interval: 1s + timeout: 2s + retries: 5 + start_period: 2s + otel-collector: image: otel/opentelemetry-collector-contrib:0.115.1 configs: @@ -43,6 +66,10 @@ services: - TRACKER_MQTT_TLS_CA_CERT=${TRACKER_MQTT_TLS_CA_CERT:-} - TRACKER_MQTT_TLS_CLIENT_CERT=${TRACKER_MQTT_TLS_CLIENT_CERT:-} - TRACKER_MQTT_TLS_CLIENT_KEY=${TRACKER_MQTT_TLS_CLIENT_KEY:-} + - TRACKER_SCENES_SOURCE=${TRACKER_SCENES_SOURCE:-api} + - TRACKER_SCENES_FILE_PATH=${TRACKER_SCENES_FILE_PATH:-scenes.json} + - TRACKER_MANAGER_URL=${TRACKER_MANAGER_URL:-} + - TRACKER_MANAGER_AUTH_PATH=${TRACKER_MANAGER_AUTH_PATH:-} # Override host proxy settings - Paho MQTT dont respect no_proxy var, so as a WA # tracker code detects empty vars and unsets them (see mqtt_client.cpp clearEmptyProxyVars) - http_proxy= @@ -57,6 +84,8 @@ services: - source: client_key target: /run/secrets/client_key mode: 0400 + - source: mock_auth + target: /run/secrets/mock-auth configs: - source: tracker-config target: /scenescape/config/tracker.json @@ -88,6 +117,8 @@ secrets: file: ${TLS_CLIENT_CERT_FILE:-/dev/null} client_key: file: ${TLS_CLIENT_KEY_FILE:-/dev/null} + mock_auth: + file: ./fixtures/test-auth.json configs: mosquitto-config: @@ -96,3 +127,7 @@ configs: file: ./otel-collector-config.yaml tracker-config: file: ../../config/tracker.json + mock-manager-script: + file: ./mocks/mock_manager.py + mock-scenes: + file: ./fixtures/test-scenes-api.json diff --git a/tracker/test/service/fixtures/test-auth.json b/tracker/test/service/fixtures/test-auth.json new file mode 100644 index 000000000..f4d1eb375 --- /dev/null +++ b/tracker/test/service/fixtures/test-auth.json @@ -0,0 +1 @@ +{ "user": "admin", "password": "testpass123" } diff --git a/tracker/test/service/fixtures/test-scenes-api.json b/tracker/test/service/fixtures/test-scenes-api.json new file mode 100644 index 000000000..790878dd5 --- /dev/null +++ b/tracker/test/service/fixtures/test-scenes-api.json @@ -0,0 +1,187 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "uid": "3bc091c7-e449-46a0-9540-29c499bca18c", + "name": "Retail", + "map_type": "map_upload", + "use_tracker": true, + "output_lla": false, + "map": "https://localhost:443/media/HazardZoneSceneLarge.png", + "cameras": [ + { + "uid": "camera1", + "name": "camera1", + "intrinsics": { + "fx": 571.2592026968458, + "fy": 571.2592026968458, + "cx": 320.0, + "cy": 240.0 + }, + "transform_type": "3d-2d point correspondence", + "transforms": [ + 278.0, 61.0, 621.0, 132.0, 559.0, 460.0, 66.0, 289.0, 0.1, 5.38, + 3.04, 5.35, 3.05, 2.42, 0.1, 2.45 + ], + "distortion": { + "k1": 0.0, + "k2": 0.0, + "p1": 0.0, + "p2": 0.0, + "k3": 0.0 + }, + "translation": [ + 2.6651330996559883, 1.0075648853123316, 2.603863333755973 + ], + "rotation": [ + -137.85924651441334, -19.441505783168942, -15.384890268257454 + ], + "scale": [1.0000000000000009, 1.0, 1.0], + "resolution": [640, 480], + "scene": "3bc091c7-e449-46a0-9540-29c499bca18c" + }, + { + "uid": "camera2", + "name": "camera2", + "intrinsics": { + "fx": 571.2592026968458, + "fy": 571.2592026968458, + "cx": 320.0, + "cy": 240.0 + }, + "transform_type": "3d-2d point correspondence", + "transforms": [ + 31.0, 228.0, 423.0, 266.0, 537.0, 385.0, 79.0, 343.0, 1.06, 5.34, + 4.0, 5.38, 4.98, 4.39, 2.04, 4.38 + ], + "distortion": { + "k1": 0.0, + "k2": 0.0, + "p1": 0.0, + "p2": 0.0, + "k3": 0.0 + }, + "translation": [ + 4.034863921795162, 2.2777663107089894, 2.9551143733918663 + ], + "rotation": [ + -132.15360745910087, -8.172752500708558, -5.298590495090165 + ], + "scale": [1.0, 1.0, 1.0], + "resolution": [640, 480], + "scene": "3bc091c7-e449-46a0-9540-29c499bca18c" + } + ], + "mesh_translation": [0, 0, 0], + "mesh_rotation": [0, 0, 0], + "mesh_scale": [1.0, 1.0, 1.0], + "scale": 100.0, + "regulated_rate": 30.0, + "external_update_rate": 30.0, + "camera_calibration": "AprilTag", + "apriltag_size": 0.147, + "map_processed": "2023-06-08T13:53:58.767000Z", + "number_of_localizations": 50, + "global_feature": "netvlad", + "local_feature": { "sift": {} }, + "matcher": { "NN-ratio": {} }, + "minimum_number_of_matches": 20, + "inlier_threshold": 0.5, + "geospatial_provider": "google", + "map_zoom": 15.0, + "map_bearing": 0.0 + }, + { + "uid": "302cf49a-97ec-402d-a324-c5077b280b7b", + "name": "Queuing", + "map_type": "map_upload", + "use_tracker": true, + "output_lla": false, + "map": "https://localhost:443/media/scene.png", + "cameras": [ + { + "uid": "atag-qcam1", + "name": "atag-qcam1", + "intrinsics": { + "fx": 905.0, + "fy": 905.0, + "cx": 640.0, + "cy": 360.0 + }, + "transform_type": "3d-2d point correspondence", + "transforms": [ + 119.0, 622.0, 257.0, 561.0, 978.0, 580.0, 628.0, 312.0, 1.685, + 2.533, 2.188, 2.578, 4.449, 1.412, 3.94, 3.228 + ], + "distortion": { + "k1": 0.0, + "k2": 0.0, + "p1": 0.0, + "p2": 0.0, + "k3": 0.0 + }, + "translation": [ + 2.985857104493509, 0.2054078898442529, 2.7150546825598902 + ], + "rotation": [ + -135.08718965001765, 12.682032394455131, 19.24508172546946 + ], + "scale": [1.0, 1.0, 1.0], + "resolution": [1280, 720], + "scene": "302cf49a-97ec-402d-a324-c5077b280b7b" + }, + { + "uid": "atag-qcam2", + "name": "atag-qcam2", + "intrinsics": { + "fx": 905.0, + "fy": 905.0, + "cx": 640.0, + "cy": 360.0 + }, + "transform_type": "3d-2d point correspondence", + "transforms": [ + 1012.0, 307.0, 956.0, 613.0, 1121.0, 505.0, 585.0, 316.0, 3.596, + 2.96, 1.583, 2.794, 2.36, 2.266, 2.577, 4.903 + ], + "distortion": { + "k1": 0.0, + "k2": 0.0, + "p1": 0.0, + "p2": 0.0, + "k3": 0.0 + }, + "translation": [ + -0.6544951215349519, 2.8628274940885503, 2.894955006060443 + ], + "rotation": [ + -150.5988934259539, 42.35138027480063, 52.29263795544898 + ], + "scale": [1.0, 1.0000000000000002, 1.0], + "resolution": [1280, 720], + "scene": "302cf49a-97ec-402d-a324-c5077b280b7b" + } + ], + "mesh_translation": [0, 0, 0], + "mesh_rotation": [0, 0, 0], + "mesh_scale": [1.0, 1.0, 1.0], + "scale": 157.0, + "regulated_rate": 30.0, + "external_update_rate": 30.0, + "camera_calibration": "AprilTag", + "apriltag_size": 0.318471338, + "map_processed": "2023-06-08T13:54:27.922000Z", + "number_of_localizations": 50, + "global_feature": "netvlad", + "local_feature": { "sift": {} }, + "matcher": { "NN-ratio": {} }, + "minimum_number_of_matches": 20, + "inlier_threshold": 0.5, + "geospatial_provider": "google", + "map_zoom": 15.0, + "map_bearing": 0.0 + } + ] +} diff --git a/tracker/test/service/mocks/mock_manager.py b/tracker/test/service/mocks/mock_manager.py new file mode 100644 index 000000000..a0096e75c --- /dev/null +++ b/tracker/test/service/mocks/mock_manager.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +""" +Mock Manager REST API server for tracker service tests. + +Implements the two endpoints the tracker uses: + POST /api/v1/auth - returns auth token + GET /api/v1/scenes - returns scene list (requires token) + +Serves a real Manager API response (complete JSON with count, next, previous, results). +The tracker's ApiSceneLoader extracts the results array and transforms it to nested schema format. +""" + +import json +import os +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs + +SCENES_PATH = os.environ.get("MOCK_SCENES_PATH", "/data/scenes.json") +LISTEN_PORT = int(os.environ.get("MOCK_PORT", "8000")) +TOKEN = "mock-test-token-12345" + + +def load_scenes(): + with open(SCENES_PATH) as f: + return json.load(f) + + +class MockManagerHandler(BaseHTTPRequestHandler): + """Minimal handler implementing Manager auth and scenes endpoints.""" + + def do_POST(self): + if self.path == "/api/v1/auth": + self._handle_auth() + else: + self._send_json(404, {"detail": "Not found."}) + + def do_GET(self): + if self.path == "/healthz": + self._send_json(200, {"status": "ok"}) + elif self.path == "/api/v1/scenes": + self._handle_scenes() + else: + self._send_json(404, {"detail": "Not found."}) + + def _handle_auth(self): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length).decode() + params = parse_qs(body) + + username = params.get("username", [None])[0] + password = params.get("password", [None])[0] + + if not username or not password: + self._send_json( + 400, { + "non_field_errors": ["Unable to log in with provided credentials."]}) + return + + self._send_json(200, {"token": TOKEN}) + + def _handle_scenes(self): + auth = self.headers.get("Authorization", "") + if auth != f"Token {TOKEN}": + self._send_json( + 401, { + "detail": "Authentication credentials were not provided."}) + return + + scenes_response = load_scenes() + self._send_json(200, scenes_response) + + def _send_json(self, code, data): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + """Log to stdout for docker compose logs visibility.""" + print(f"[mock-manager] {args[0]}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", LISTEN_PORT), MockManagerHandler) + print( + f"[mock-manager] Listening on port {LISTEN_PORT}, scenes: {SCENES_PATH}") + server.serve_forever() diff --git a/tracker/test/service/test_mqtt.py b/tracker/test/service/test_mqtt.py index 2422fb5c0..4157a73ba 100644 --- a/tracker/test/service/test_mqtt.py +++ b/tracker/test/service/test_mqtt.py @@ -110,6 +110,7 @@ def tls_tracker_service(tls_certs): f"TRACKER_MQTT_TLS_CA_CERT=/run/secrets/ca_cert\n" f"TRACKER_MQTT_TLS_CLIENT_CERT=/run/secrets/client_cert\n" f"TRACKER_MQTT_TLS_CLIENT_KEY=/run/secrets/client_key\n" + f"TRACKER_SCENES_SOURCE=file\n" ) docker = DockerClient( diff --git a/tracker/test/service/test_scene_loading.py b/tracker/test/service/test_scene_loading.py new file mode 100644 index 000000000..f05ce4269 --- /dev/null +++ b/tracker/test/service/test_scene_loading.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +""" +API scene loading service tests for tracker. + +Tests the full dynamic scene loading path via mock Manager REST API: +- Auth file reading and authentication +- Scene fetching with token-based authorization +- API flat format -> schema nested format transformation +- Schema validation and scene registration +- MQTT subscriptions based on API-loaded cameras +""" + +import json +import time +import uuid +from datetime import datetime, timezone + +import paho.mqtt.client as mqtt +from waiting import wait + +from utils.docker import ( + get_container_logs, + is_tracker_ready, + DEFAULT_TIMEOUT, + POLL_INTERVAL, +) + +# Expected camera/scene from test-scenes-api.json (same UIDs as config/scenes.json) +EXPECTED_SCENE_UID = "302cf49a-97ec-402d-a324-c5077b280b7b" +EXPECTED_CAMERA_UIDS = ["atag-qcam1", "atag-qcam2", "camera1", "camera2"] +TOPIC_CAMERA_INPUT = "scenescape/data/camera/atag-qcam1" +TOPIC_SCENE_OUTPUT = f"scenescape/data/scene/{EXPECTED_SCENE_UID}/thing" + + +def test_api_scene_loading(tracker_service_api): + """ + Test that tracker loads scenes from mock Manager API and becomes ready. + + Verifies the full API path: + 1. Tracker reads auth file (test-auth.json) + 2. POSTs to mock-manager /api/v1/auth -> gets token + 3. GETs /api/v1/scenes with token -> gets scenes in flat API format + 4. Transforms flat format to nested schema format + 5. Validates against scene.schema.json + 6. Registers scenes and subscribes to camera topics + 7. Becomes ready (healthcheck passes) + """ + docker = tracker_service_api["docker"] + + # Tracker should already be ready (fixture waits for readiness) + assert is_tracker_ready(docker), "Tracker should be ready after API scene loading" + + # Verify logs confirm API loading path was used + logs = get_container_logs(docker, "tracker") + assert "Authenticated with Manager API" in logs, \ + f"Expected auth log. Got:\n{logs[-500:]}" + assert "Fetched scenes from Manager API" in logs, \ + f"Expected fetch log. Got:\n{logs[-500:]}" + assert "Loaded 2 scenes from Manager API" in logs, \ + f"Expected '2 scenes' log. Got:\n{logs[-500:]}" + assert "Loaded 2 scenes with 4 cameras" in logs, \ + f"Expected '4 cameras' log. Got:\n{logs[-500:]}" + + # Verify subscriptions for all cameras from API-loaded scenes + for camera_uid in EXPECTED_CAMERA_UIDS: + expected_topic = f"scenescape/data/camera/{camera_uid}" + assert expected_topic in logs, \ + f"Expected subscription to {expected_topic}. Got:\n{logs[-500:]}" + + print("\nAPI scene loading verified: auth -> fetch -> transform -> subscribe") + + +def test_api_scene_message_flow(tracker_service_api): + """ + Test end-to-end message flow with API-loaded scenes. + + Verifies that scenes loaded via the API path produce the same + functional behavior as file-loaded scenes: camera detections + are processed and scene output is published. + """ + docker = tracker_service_api["docker"] + + assert is_tracker_ready(docker), "Tracker should be ready" + + # Connect to broker from host (non-TLS, port 1883) + containers = docker.compose.ps() + broker_port = 1883 + for container in containers: + if "-broker-" in container.name: + ports = container.network_settings.ports + if "1883/tcp" in ports and ports["1883/tcp"]: + broker_port = int(ports["1883/tcp"][0]["HostPort"]) + break + + received_messages = [] + + def on_message(client, userdata, msg): + received_messages.append(json.loads(msg.payload.decode())) + + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"test-api-{uuid.uuid4().hex[:8]}" + ) + client.on_message = on_message + client.connect("localhost", broker_port, keepalive=60) + client.loop_start() + + try: + # Subscribe to scene output + client.subscribe(TOPIC_SCENE_OUTPUT, qos=1) + + # Send multiple detections with current timestamps (tracking pipeline + # drops stale messages via max_lag_s and needs repeated detections + # before RobotVision produces reliable tracks) + for i in range(5): + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + detection = { + "id": "atag-qcam1", + "timestamp": timestamp, + "objects": { + "thing": [ + { + "id": 1, + "bounding_box_px": {"x": 100, "y": 50, "width": 80, "height": 200} + } + ] + } + } + result = client.publish(TOPIC_CAMERA_INPUT, json.dumps(detection), qos=1) + result.wait_for_publish() + if i < 4: + time.sleep(0.067) # ~15 FPS + + # Wait for scene output + wait( + lambda: len(received_messages) > 0, + timeout_seconds=DEFAULT_TIMEOUT, + sleep_seconds=POLL_INTERVAL + ) + + assert len(received_messages) > 0, "Should receive scene output from API-loaded scene" + print(f"\nAPI message flow verified: received {len(received_messages)} scene output(s)") + + finally: + client.loop_stop() + client.disconnect() diff --git a/tracker/test/service/test_scene_update.py b/tracker/test/service/test_scene_update.py new file mode 100644 index 000000000..f2161ed27 --- /dev/null +++ b/tracker/test/service/test_scene_update.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +""" +Database update notification tests for tracker service (dynamic mode). + +Tests that in API (dynamic) mode, publishing to scenescape/cmd/database +triggers a graceful shutdown and Docker restart, per the design doc: + "On notification: logs change, exits gracefully (Docker restarts the service + which loads new config at startup)" +""" + +import uuid + +import paho.mqtt.client as mqtt +from waiting import wait, TimeoutExpired + +from utils.docker import ( + get_broker_host, + get_container_logs, + is_tracker_ready, + wait_for_readiness, + DEFAULT_TIMEOUT, + POLL_INTERVAL, +) + +# Topic for database update notifications (matches Manager's sendUpdateCommand) +TOPIC_DATABASE_UPDATE = "scenescape/cmd/database" + + +def _count_startups(docker): + """Count how many times 'Tracker service starting' appears in logs.""" + logs = get_container_logs(docker, "tracker") + return logs.count("Tracker service starting") + + +def test_database_update_triggers_restart(tracker_service_api): + """ + Test that publishing a database update notification causes graceful shutdown + and automatic restart in API (dynamic) mode. + + Phases: + 1. Verify tracker is ready (API scenes loaded) + 2. Publish database update notification + 3. Wait for restart via log-based detection (startup count increases) + 4. Verify logs contain expected database update messages + """ + docker = tracker_service_api["docker"] + + # Phase 1: Tracker should be ready (fixture ensures this) + assert is_tracker_ready(docker), "Tracker should be ready in API mode" + initial_startups = _count_startups(docker) + assert initial_startups >= 1, "Tracker should have started at least once" + print("\nPhase 1: Tracker ready with API-loaded scenes") + + # Phase 2: Connect to broker and publish database update notification + host, port = get_broker_host(docker) + + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"test-update-{uuid.uuid4().hex[:8]}" + ) + client.connect(host, port, keepalive=60) + client.loop_start() + + try: + result = client.publish(TOPIC_DATABASE_UPDATE, "update", qos=1) + result.wait_for_publish() + print("Phase 2: Published database update notification") + finally: + client.loop_stop() + client.disconnect() + + # Phase 3: Wait for the tracker to restart by observing an additional + # "Tracker service starting" entry in the logs. Docker restart: on-failure + # restarts the container quickly (~300ms), which is too fast for health-check + # polling to catch the brief "not ready" window. Log-based detection is + # deterministic and avoids that race condition. + try: + wait( + lambda: _count_startups(docker) > initial_startups, + timeout_seconds=DEFAULT_TIMEOUT, + sleep_seconds=POLL_INTERVAL + ) + print("Phase 3: Tracker restarted after database update") + except TimeoutExpired: + logs = get_container_logs(docker, "tracker") + raise AssertionError( + f"Tracker did not restart after database update. Logs:\n{logs[-500:]}" + ) + + # Phase 4: Wait for the restarted tracker to become ready + try: + wait_for_readiness(docker, timeout=30) + print("Phase 4: Restarted tracker became ready again") + except TimeoutExpired: + logs = get_container_logs(docker, "tracker") + raise AssertionError( + f"Tracker did not become ready after restart. Logs:\n{logs[-500:]}" + ) + + # Phase 5: Verify logs contain database update message + logs = get_container_logs(docker, "tracker") + assert "Database update received" in logs, \ + f"Expected 'Database update received' in logs. Got:\n{logs[-500:]}" + assert "triggering restart" in logs, \ + f"Expected 'triggering restart' in logs. Got:\n{logs[-500:]}" + assert "database update restart" in logs.lower(), \ + f"Expected 'database update restart' in logs. Got:\n{logs[-500:]}" + + print("\nAll database update restart phases passed") diff --git a/tracker/test/unit/CMakeLists.txt b/tracker/test/unit/CMakeLists.txt index e86816c64..22dd0790a 100644 --- a/tracker/test/unit/CMakeLists.txt +++ b/tracker/test/unit/CMakeLists.txt @@ -3,6 +3,7 @@ find_package(GTest REQUIRED) find_package(stduuid REQUIRED) +find_package(OpenSSL REQUIRED) # Tracker library sources for testing (exclude main.cpp) set(TRACKER_LIB_SOURCES @@ -10,6 +11,8 @@ set(TRACKER_LIB_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../src/cli.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/config_loader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/scene_loader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/api_scene_loader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/manager_rest_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/coordinate_transformer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/healthcheck_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/healthcheck_command.cpp @@ -45,6 +48,8 @@ add_executable(tracker_tests time_utils_test.cpp uuid_test.cpp id_map_test.cpp + api_scene_loader_test.cpp + manager_rest_client_test.cpp ${TRACKER_LIB_SOURCES} ) @@ -78,6 +83,8 @@ target_link_libraries(tracker_tests OpenMP::OpenMP_CXX scenescape::security_options PahoMqttCpp::paho-mqttpp3-static + OpenSSL::SSL + OpenSSL::Crypto stduuid::stduuid ) diff --git a/tracker/test/unit/api_scene_loader_test.cpp b/tracker/test/unit/api_scene_loader_test.cpp new file mode 100644 index 000000000..3b715b20f --- /dev/null +++ b/tracker/test/unit/api_scene_loader_test.cpp @@ -0,0 +1,667 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "config_loader.hpp" +#include "logger.hpp" +#include "scene_loader.hpp" +#include "scene_parser.hpp" + +#include "utils/mock_manager_rest_client.hpp" + +#include +#include +#include + +namespace tracker { +namespace { + +using ::testing::_; +using ::testing::Return; + +// --------------------------------------------------------------------------- +// RAII temp file helper +// --------------------------------------------------------------------------- +class TempFile { +public: + TempFile(const std::string& content, const std::string& suffix = ".json") { + path_ = std::filesystem::temp_directory_path() / + ("api_test_" + std::to_string(counter_++) + suffix); + std::ofstream ofs(path_); + ofs << content; + } + + ~TempFile() { std::filesystem::remove(path_); } + + const std::filesystem::path& path() const { return path_; } + +private: + std::filesystem::path path_; + static inline int counter_ = 0; +}; + +// --------------------------------------------------------------------------- +// Helper: create a mock client factory returning a pre-configured mock +// --------------------------------------------------------------------------- +ManagerClientFactory make_mock_factory(const std::string& scenes_response) { + return [scenes_response](const ManagerConfig&) -> std::unique_ptr { + auto mock = std::make_unique(); + EXPECT_CALL(*mock, authenticate(_, _)).Times(1); + EXPECT_CALL(*mock, fetchScenes()).WillOnce(Return(scenes_response)); + return mock; + }; +} + +// --------------------------------------------------------------------------- +// Factory-level tests for create_scene_loader with SceneSource::Api +// --------------------------------------------------------------------------- + +TEST(ApiSceneLoaderTest, FactoryRequiresManagerConfig) { + ScenesConfig config; + config.source = SceneSource::Api; + + // No manager config provided -> should throw + EXPECT_THROW(create_scene_loader(config, "/tmp", std::nullopt, "/tmp"), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, FactoryRequiresSchemaDir) { + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = "/tmp/nonexistent-auth.json"; + + // Empty schema_dir -> should throw with clear message + EXPECT_THROW(create_scene_loader(config, "/tmp", mgr, ""), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, FactoryCreatesLoaderWithManagerConfig) { + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = "/tmp/nonexistent-auth.json"; + + // Should return a valid loader (auth file doesn't need to exist yet) + auto loader = create_scene_loader(config, "/tmp", mgr, "/tmp"); + ASSERT_NE(loader, nullptr); +} + +TEST(ApiSceneLoaderTest, LoadFailsWithInvalidAuthFile) { + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = "/tmp/nonexistent-auth-file.json"; + + auto loader = create_scene_loader(config, "/tmp", mgr, "/tmp"); + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, LoadFailsWithMalformedAuthFile) { + TempFile auth_file("not valid json"); + + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = auth_file.path().string(); + + auto loader = create_scene_loader(config, "/tmp", mgr, "/tmp"); + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, LoadFailsWithMissingUserField) { + TempFile auth_file(R"({"password": "pass123"})"); + + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = auth_file.path().string(); + + auto loader = create_scene_loader(config, "/tmp", mgr, "/tmp"); + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, LoadFailsWithMissingPasswordField) { + TempFile auth_file(R"({"user": "admin"})"); + + ScenesConfig config; + config.source = SceneSource::Api; + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = auth_file.path().string(); + + auto loader = create_scene_loader(config, "/tmp", mgr, "/tmp"); + EXPECT_THROW(loader->load(), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// File-mode factory tests (regression) +// --------------------------------------------------------------------------- + +TEST(ApiSceneLoaderTest, FileSourceStillWorksWithDefaultParams) { + ScenesConfig config; + config.source = SceneSource::File; + config.file_path = "/nonexistent/scenes.json"; + + auto loader = create_scene_loader(config, "/tmp"); + ASSERT_NE(loader, nullptr); + // Should throw because file doesn't exist (not because of factory issues) + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST(ApiSceneLoaderTest, FileSourceMissingFilePathThrows) { + ScenesConfig config; + config.source = SceneSource::File; + + EXPECT_THROW(create_scene_loader(config, "/tmp"), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// detail::read_auth_file tests +// --------------------------------------------------------------------------- + +TEST(ReadAuthFileTest, ValidAuthFile) { + TempFile auth_file(R"({"user": "admin", "password": "secret123"})"); + auto [user, pass] = detail::read_auth_file(auth_file.path().string()); + EXPECT_EQ(user, "admin"); + EXPECT_EQ(pass, "secret123"); +} + +TEST(ReadAuthFileTest, MissingFileThrows) { + EXPECT_THROW(detail::read_auth_file("/nonexistent/auth.json"), std::runtime_error); +} + +TEST(ReadAuthFileTest, InvalidJsonThrows) { + TempFile auth_file("{not json"); + EXPECT_THROW(detail::read_auth_file(auth_file.path().string()), std::runtime_error); +} + +TEST(ReadAuthFileTest, MissingUserFieldThrows) { + TempFile auth_file(R"({"password": "pass"})"); + EXPECT_THROW(detail::read_auth_file(auth_file.path().string()), std::runtime_error); +} + +TEST(ReadAuthFileTest, MissingPasswordFieldThrows) { + TempFile auth_file(R"({"user": "admin"})"); + EXPECT_THROW(detail::read_auth_file(auth_file.path().string()), std::runtime_error); +} + +TEST(ReadAuthFileTest, NonStringUserThrows) { + TempFile auth_file(R"({"user": 123, "password": "pass"})"); + EXPECT_THROW(detail::read_auth_file(auth_file.path().string()), std::runtime_error); +} + +TEST(ReadAuthFileTest, NonStringPasswordThrows) { + TempFile auth_file(R"({"user": "admin", "password": true})"); + EXPECT_THROW(detail::read_auth_file(auth_file.path().string()), std::runtime_error); +} + +TEST(ReadAuthFileTest, AuthFileWithWhitespace) { + TempFile auth_file(R"({"user": "admin", "password": "pass123"} +)"); + auto [user, pass] = detail::read_auth_file(auth_file.path().string()); + EXPECT_EQ(user, "admin"); + EXPECT_EQ(pass, "pass123"); +} + +// --------------------------------------------------------------------------- +// detail::transform_camera_to_schema tests +// --------------------------------------------------------------------------- + +TEST(TransformCameraTest, FlatFieldsToNestedExtrinsics) { + const char* json = R"({ + "uid": "cam1", "name": "cam1", + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + })"; + rapidjson::Document doc; + doc.Parse(json); + auto& alloc = doc.GetAllocator(); + + detail::transform_camera_to_schema(doc, alloc); + + ASSERT_TRUE(doc.HasMember("extrinsics")); + auto& ext = doc["extrinsics"]; + ASSERT_TRUE(ext.HasMember("translation")); + EXPECT_DOUBLE_EQ(ext["translation"][0].GetDouble(), 1.0); + EXPECT_DOUBLE_EQ(ext["translation"][1].GetDouble(), 2.0); + EXPECT_DOUBLE_EQ(ext["translation"][2].GetDouble(), 3.0); + ASSERT_TRUE(ext.HasMember("rotation")); + ASSERT_TRUE(ext.HasMember("scale")); +} + +TEST(TransformCameraTest, AlreadyNestedExtrinsicsUnchanged) { + const char* json = R"({ + "uid": "cam1", "name": "cam1", + "extrinsics": { + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + } + })"; + rapidjson::Document doc; + doc.Parse(json); + auto& alloc = doc.GetAllocator(); + + detail::transform_camera_to_schema(doc, alloc); + + // extrinsics should be unchanged + EXPECT_DOUBLE_EQ(doc["extrinsics"]["translation"][0].GetDouble(), 1.0); +} + +TEST(TransformCameraTest, DistortionMovedInsideIntrinsics) { + const char* json = R"({ + "uid": "cam1", "name": "cam1", + "distortion": {"k1": 0.1, "k2": 0.2, "p1": 0.01, "p2": 0.02}, + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + })"; + rapidjson::Document doc; + doc.Parse(json); + auto& alloc = doc.GetAllocator(); + + detail::transform_camera_to_schema(doc, alloc); + + ASSERT_TRUE(doc.HasMember("intrinsics")); + ASSERT_TRUE(doc["intrinsics"].HasMember("distortion")); + EXPECT_DOUBLE_EQ(doc["intrinsics"]["distortion"]["k1"].GetDouble(), 0.1); +} + +TEST(TransformCameraTest, DistortionMergesIntoExistingIntrinsics) { + const char* json = R"({ + "uid": "cam1", "name": "cam1", + "intrinsics": {"fx": 500.0}, + "distortion": {"k1": 0.1, "k2": 0.2, "p1": 0.01, "p2": 0.02}, + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + })"; + rapidjson::Document doc; + doc.Parse(json); + auto& alloc = doc.GetAllocator(); + + detail::transform_camera_to_schema(doc, alloc); + + ASSERT_TRUE(doc["intrinsics"].HasMember("distortion")); + EXPECT_DOUBLE_EQ(doc["intrinsics"]["distortion"]["k1"].GetDouble(), 0.1); + // Original fx should still be there + EXPECT_DOUBLE_EQ(doc["intrinsics"]["fx"].GetDouble(), 500.0); +} + +TEST(TransformCameraTest, DistortionAlreadyInsideIntrinsicsUnchanged) { + const char* json = R"({ + "uid": "cam1", "name": "cam1", + "intrinsics": {"distortion": {"k1": 0.5}}, + "distortion": {"k1": 0.9}, + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + })"; + rapidjson::Document doc; + doc.Parse(json); + auto& alloc = doc.GetAllocator(); + + detail::transform_camera_to_schema(doc, alloc); + + // Should keep the existing intrinsics.distortion (not overwrite) + EXPECT_DOUBLE_EQ(doc["intrinsics"]["distortion"]["k1"].GetDouble(), 0.5); +} + +// --------------------------------------------------------------------------- +// detail::transform_api_scenes tests +// --------------------------------------------------------------------------- + +TEST(TransformApiScenesTest, TransformsArrayOfScenes) { + const char* json = R"([{ + "uid": "scene1", "name": "Scene 1", + "cameras": [{ + "uid": "cam1", "name": "cam1", + "translation": [1.0, 2.0, 3.0], + "rotation": [10.0, 20.0, 30.0], + "scale": [1.0, 1.0, 1.0] + }] + }])"; + rapidjson::Document doc; + doc.Parse(json); + + detail::transform_api_scenes(doc); + + ASSERT_TRUE(doc[0]["cameras"][0].HasMember("extrinsics")); + EXPECT_DOUBLE_EQ(doc[0]["cameras"][0]["extrinsics"]["translation"][0].GetDouble(), 1.0); +} + +TEST(TransformApiScenesTest, NonArrayInputIsNoOp) { + rapidjson::Document doc; + doc.SetObject(); + detail::transform_api_scenes(doc); + EXPECT_TRUE(doc.IsObject()); +} + +TEST(TransformApiScenesTest, SceneWithNoCamerasIsSkipped) { + const char* json = R"([{"uid": "scene1", "name": "Scene 1"}])"; + rapidjson::Document doc; + doc.Parse(json); + + detail::transform_api_scenes(doc); + // No crash, no cameras added + EXPECT_FALSE(doc[0].HasMember("cameras")); +} + +// --------------------------------------------------------------------------- +// detail::validate_scenes tests +// --------------------------------------------------------------------------- + +class ValidateScenesTest : public ::testing::Test { +protected: + void SetUp() override { Logger::init("warn"); } + void TearDown() override { Logger::shutdown(); } + + std::filesystem::path schema_path_ = + std::filesystem::path(TRACKER_SCHEMA_DIR) / "scene.schema.json"; +}; + +TEST_F(ValidateScenesTest, ValidScenePasses) { + const char* json = R"([{ + "uid": "3bc091c7-e449-46a0-9540-29c499bca18c", + "name": "Retail", + "cameras": [{ + "uid": "camera1", "name": "camera1", + "intrinsics": { + "fx": 571.26, "fy": 571.26, "cx": 320.0, "cy": 240.0, + "distortion": {"k1": 0.0, "k2": 0.0, "p1": 0.0, "p2": 0.0} + }, + "extrinsics": { + "translation": [2.665, 1.008, 2.604], + "rotation": [-137.859, -19.441, -15.385], + "scale": [1.0, 1.0, 1.0] + } + }] + }])"; + rapidjson::Document doc; + doc.Parse(json); + + auto result = detail::validate_scenes(doc, schema_path_); + EXPECT_EQ(result.GetArray().Size(), 1); +} + +TEST_F(ValidateScenesTest, InvalidSceneSkipped) { + // Missing required "uid" field — scene should be skipped, not throw + const char* json = R"([{"name": "Bad Scene", "cameras": []}])"; + rapidjson::Document doc; + doc.Parse(json); + + auto result = detail::validate_scenes(doc, schema_path_); + EXPECT_EQ(result.GetArray().Size(), 0); +} + +TEST_F(ValidateScenesTest, MixOfValidAndInvalidScenes) { + // First scene is invalid (missing uid), second is valid + const char* json = R"([ + {"name": "Bad Scene"}, + { + "uid": "valid-uid", + "name": "Good Scene", + "cameras": [{ + "uid": "cam1", "name": "cam1", + "intrinsics": { + "fx": 571.26, "fy": 571.26, "cx": 320.0, "cy": 240.0, + "distortion": {"k1": 0.0, "k2": 0.0, "p1": 0.0, "p2": 0.0} + }, + "extrinsics": { + "translation": [2.665, 1.008, 2.604], + "rotation": [-137.859, -19.441, -15.385], + "scale": [1.0, 1.0, 1.0] + } + }] + } + ])"; + rapidjson::Document doc; + doc.Parse(json); + + auto result = detail::validate_scenes(doc, schema_path_); + EXPECT_EQ(result.GetArray().Size(), 1); + EXPECT_STREQ(result[0]["name"].GetString(), "Good Scene"); +} + +TEST_F(ValidateScenesTest, EmptyArrayReturnsEmpty) { + rapidjson::Document doc; + doc.SetArray(); + + auto result = detail::validate_scenes(doc, schema_path_); + EXPECT_EQ(result.GetArray().Size(), 0); +} + +TEST(ValidateScenesTest_NoFixture, MissingSchemaFileThrows) { + rapidjson::Document doc; + doc.SetArray(); + EXPECT_THROW(detail::validate_scenes(doc, "/nonexistent/schema.json"), std::runtime_error); +} + +TEST(ValidateScenesTest_NoFixture, MalformedSchemaFileThrows) { + TempFile bad_schema("this is not valid json {{{"); + rapidjson::Document doc; + doc.SetArray(); + EXPECT_THROW(detail::validate_scenes(doc, bad_schema.path()), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// detail::read_file_trimmed tests +// --------------------------------------------------------------------------- + +TEST(ReadFileTrimmedTest, ReadsAndTrimsTrailingWhitespace) { + TempFile f("hello world \n\n"); + EXPECT_EQ(detail::read_file_trimmed(f.path()), "hello world"); +} + +TEST(ReadFileTrimmedTest, MissingFileThrows) { + EXPECT_THROW(detail::read_file_trimmed("/nonexistent/file.txt"), std::runtime_error); +} + +// --------------------------------------------------------------------------- +// Full ApiSceneLoader pipeline via mock +// --------------------------------------------------------------------------- + +class ApiSceneLoaderPipelineTest : public ::testing::Test { +protected: + void SetUp() override { Logger::init("warn"); } + void TearDown() override { Logger::shutdown(); } + + std::filesystem::path schema_dir_ = std::filesystem::path(TRACKER_SCHEMA_DIR); + + // Minimal valid Manager API response with one scene and one camera + std::string make_api_response() { + return R"({ + "results": [{ + "uid": "scene-001", + "name": "TestScene", + "cameras": [{ + "uid": "cam1", + "name": "Camera 1", + "translation": [1.0, 2.0, 3.0], + "rotation": [-90.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0], + "distortion": {"k1": 0.1, "k2": 0.0, "p1": 0.0, "p2": 0.0}, + "intrinsics": { + "fx": 500.0, "fy": 500.0, + "cx": 320.0, "cy": 240.0 + } + }] + }] + })"; + } +}; + +TEST_F(ApiSceneLoaderPipelineTest, FullPipelineReturnsScenes) { + TempFile auth_file(R"({"user": "admin", "password": "pass123"})"); + + ManagerConfig mgr; + mgr.url = "https://localhost:443"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(make_api_response()); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + auto scenes = loader->load(); + ASSERT_EQ(scenes.size(), 1); + EXPECT_EQ(scenes[0].uid, "scene-001"); + EXPECT_EQ(scenes[0].name, "TestScene"); + ASSERT_EQ(scenes[0].cameras.size(), 1); + EXPECT_EQ(scenes[0].cameras[0].uid, "cam1"); + EXPECT_DOUBLE_EQ(scenes[0].cameras[0].extrinsics.translation[0], 1.0); + EXPECT_DOUBLE_EQ(scenes[0].cameras[0].extrinsics.translation[1], 2.0); + EXPECT_DOUBLE_EQ(scenes[0].cameras[0].extrinsics.translation[2], 3.0); + EXPECT_DOUBLE_EQ(scenes[0].cameras[0].intrinsics.fx, 500.0); + EXPECT_DOUBLE_EQ(scenes[0].cameras[0].intrinsics.distortion.k1, 0.1); +} + +TEST_F(ApiSceneLoaderPipelineTest, MultipleScenesAndCameras) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + std::string response = R"({ + "results": [ + { + "uid": "scene-1", "name": "Scene One", + "cameras": [ + { + "uid": "cam1", "name": "Cam 1", + "translation": [1.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0] + }, + { + "uid": "cam2", "name": "Cam 2", + "translation": [2.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0] + } + ] + }, + { + "uid": "scene-2", "name": "Scene Two", + "cameras": [{ + "uid": "cam3", "name": "Cam 3", + "translation": [3.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0] + }] + } + ] + })"; + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(response); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + auto scenes = loader->load(); + ASSERT_EQ(scenes.size(), 2); + EXPECT_EQ(scenes[0].cameras.size(), 2); + EXPECT_EQ(scenes[1].cameras.size(), 1); + EXPECT_EQ(scenes[1].cameras[0].uid, "cam3"); +} + +TEST_F(ApiSceneLoaderPipelineTest, InvalidJsonResponseThrows) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory("not json at all"); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST_F(ApiSceneLoaderPipelineTest, MissingResultsArrayThrows) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(R"({"data": []})"); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST_F(ApiSceneLoaderPipelineTest, ResultsNotArrayThrows) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(R"({"results": "not an array"})"); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + EXPECT_THROW(loader->load(), std::runtime_error); +} + +TEST_F(ApiSceneLoaderPipelineTest, EmptyResultsReturnsNoScenes) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(R"({"results": []})"); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + auto scenes = loader->load(); + EXPECT_TRUE(scenes.empty()); +} + +TEST_F(ApiSceneLoaderPipelineTest, SchemaValidationFailureSkipsInvalidScenes) { + TempFile auth_file(R"({"user": "admin", "password": "pass"})"); + // Scene missing required "uid" field — should be skipped, not throw + std::string response = R"({"results": [{"name": "Bad", "cameras": []}]})"; + + ManagerConfig mgr; + mgr.url = "https://localhost"; + mgr.auth_path = auth_file.path().string(); + + auto factory = make_mock_factory(response); + auto loader = create_api_scene_loader(mgr, schema_dir_, factory); + + auto scenes = loader->load(); + EXPECT_TRUE(scenes.empty()); +} + +// --------------------------------------------------------------------------- +// scene_parser.hpp coverage: require_array3 non-number element (lines 45-46) +// --------------------------------------------------------------------------- + +TEST(SceneParserTest, RequireArray3NonNumberElementThrows) { + // Translation array with string instead of number + const char* json = R"({ + "uid": "scene1", "name": "Scene", + "cameras": [{ + "uid": "cam1", "name": "cam1", + "extrinsics": { + "translation": [1.0, "not-a-number", 3.0], + "rotation": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0] + } + }] + })"; + rapidjson::Document doc; + doc.Parse(json); + + EXPECT_THROW(detail::parse_scene(doc), std::runtime_error); +} + +} // namespace +} // namespace tracker diff --git a/tracker/test/unit/config_loader_test.cpp b/tracker/test/unit/config_loader_test.cpp index ca78b161f..7d44a7aa4 100644 --- a/tracker/test/unit/config_loader_test.cpp +++ b/tracker/test/unit/config_loader_test.cpp @@ -654,9 +654,11 @@ TEST(ConfigLoaderTest, ScenesSourceEnvOverride) { EXPECT_EQ(config.scenes.source, SceneSource::File); } - // Override to api + // Override to api (also requires manager config) { - ScopedEnv env(tracker::env::SCENES_SOURCE, "api"); + ScopedEnv env_src(tracker::env::SCENES_SOURCE, "api"); + ScopedEnv env_mgr(tracker::env::MANAGER_URL, "https://manager.test"); + ScopedEnv env_auth(tracker::env::MANAGER_AUTH_PATH, "/auth"); auto config = load_config(config_file.path(), get_schema_path()); EXPECT_EQ(config.scenes.source, SceneSource::Api); } @@ -1195,5 +1197,172 @@ TEST(ConfigLoaderTest, CameraNotObjectThrows) { EXPECT_THROW(scene_loader->load(), std::runtime_error); } +// +// API scenes source with Manager config from JSON file (lines 222-262) +// + +/// Helper to create config with api source and manager section +std::string config_with_api_source() { + return R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true}, + "manager": { + "url": "https://manager.example.com", + "auth_path": "/tmp/auth.json", + "ca_cert_path": "/tmp/ca.pem" + } + }, + "scenes": { + "source": "api" + } + })"; +} + +TEST(ConfigLoaderTest, LoadApiSourceConfig) { + TempFile config_file(config_with_api_source()); + auto config = load_config(config_file.path(), get_schema_path()); + + EXPECT_EQ(config.scenes.source, SceneSource::Api); + ASSERT_TRUE(config.infrastructure.manager.has_value()); + EXPECT_EQ(config.infrastructure.manager->url, "https://manager.example.com"); + EXPECT_EQ(config.infrastructure.manager->auth_path, "/tmp/auth.json"); + ASSERT_TRUE(config.infrastructure.manager->ca_cert_path.has_value()); + EXPECT_EQ(config.infrastructure.manager->ca_cert_path.value(), "/tmp/ca.pem"); +} + +TEST(ConfigLoaderTest, LoadApiSourceWithoutCaCert) { + std::string json = R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true}, + "manager": { + "url": "https://manager.example.com", + "auth_path": "/tmp/auth.json" + } + }, + "scenes": { + "source": "api" + } + })"; + TempFile config_file(json); + auto config = load_config(config_file.path(), get_schema_path()); + + ASSERT_TRUE(config.infrastructure.manager.has_value()); + EXPECT_FALSE(config.infrastructure.manager->ca_cert_path.has_value()); +} + +TEST(ConfigLoaderTest, ApiSourceWithoutManagerThrows) { + std::string json = R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true} + }, + "scenes": { + "source": "api" + } + })"; + TempFile config_file(json); + EXPECT_THROW(load_config(config_file.path(), get_schema_path()), std::runtime_error); +} + +TEST(ConfigLoaderTest, ApiSourceMissingManagerUrlThrows) { + std::string json = R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true}, + "manager": { + "auth_path": "/tmp/auth.json" + } + }, + "scenes": { + "source": "api" + } + })"; + TempFile config_file(json); + EXPECT_THROW(load_config(config_file.path(), get_schema_path()), std::runtime_error); +} + +TEST(ConfigLoaderTest, ApiSourceMissingManagerAuthPathThrows) { + std::string json = R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true}, + "manager": { + "url": "https://manager.example.com" + } + }, + "scenes": { + "source": "api" + } + })"; + TempFile config_file(json); + EXPECT_THROW(load_config(config_file.path(), get_schema_path()), std::runtime_error); +} + +TEST(ConfigLoaderTest, InvalidScenesSourceInConfigThrows) { + std::string json = R"({ + "infrastructure": { + "mqtt": {"host": "localhost", "port": 1883, "insecure": true} + }, + "scenes": { + "source": "database" + } + })"; + TempFile config_file(json); + EXPECT_THROW(load_config(config_file.path(), get_schema_path()), std::runtime_error); +} + +// +// Manager env var override tests (lines 385-398) +// + +TEST(ConfigLoaderTest, ManagerEnvVarOverrides_AllFields) { + TempFile config_file(MINIMAL_CONFIG()); + + ScopedEnv env_src(tracker::env::SCENES_SOURCE, "api"); + ScopedEnv env_url(tracker::env::MANAGER_URL, "https://env-manager.test"); + ScopedEnv env_auth(tracker::env::MANAGER_AUTH_PATH, "/env/auth.json"); + ScopedEnv env_ca(tracker::env::MANAGER_CA_CERT_PATH, "/env/ca.pem"); + + auto config = load_config(config_file.path(), get_schema_path()); + ASSERT_TRUE(config.infrastructure.manager.has_value()); + EXPECT_EQ(config.infrastructure.manager->url, "https://env-manager.test"); + EXPECT_EQ(config.infrastructure.manager->auth_path, "/env/auth.json"); + ASSERT_TRUE(config.infrastructure.manager->ca_cert_path.has_value()); + EXPECT_EQ(config.infrastructure.manager->ca_cert_path.value(), "/env/ca.pem"); +} + +TEST(ConfigLoaderTest, ManagerEnvVarOverrides_UrlOnlyCreatesManagerConfig) { + TempFile config_file(MINIMAL_CONFIG()); + + ScopedEnv env_url(tracker::env::MANAGER_URL, "https://env-only.test"); + + auto config = load_config(config_file.path(), get_schema_path()); + ASSERT_TRUE(config.infrastructure.manager.has_value()); + EXPECT_EQ(config.infrastructure.manager->url, "https://env-only.test"); +} + +TEST(ConfigLoaderTest, ManagerEnvVarOverrides_OverridesExisting) { + TempFile config_file(config_with_api_source()); + + ScopedEnv env_url(tracker::env::MANAGER_URL, "https://overridden.test"); + ScopedEnv env_auth(tracker::env::MANAGER_AUTH_PATH, "/overridden/auth.json"); + + auto config = load_config(config_file.path(), get_schema_path()); + ASSERT_TRUE(config.infrastructure.manager.has_value()); + EXPECT_EQ(config.infrastructure.manager->url, "https://overridden.test"); + EXPECT_EQ(config.infrastructure.manager->auth_path, "/overridden/auth.json"); +} + +// +// parse_positive_double out_of_range path (line 353) +// + +TEST(ConfigLoaderTest, PositiveDoubleEnvOverride_OutOfRange) { + TempFile config_file(MINIMAL_CONFIG()); + + // Use a value that causes std::stod to throw std::out_of_range + std::string huge_value = "1e99999"; + ScopedEnv env(tracker::env::MAX_UNRELIABLE_TIME_S, huge_value.c_str()); + + EXPECT_THROW(load_config(config_file.path(), get_schema_path()), std::runtime_error); +} + } // namespace } // namespace tracker diff --git a/tracker/test/unit/manager_rest_client_test.cpp b/tracker/test/unit/manager_rest_client_test.cpp new file mode 100644 index 000000000..2f15f4ed2 --- /dev/null +++ b/tracker/test/unit/manager_rest_client_test.cpp @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "logger.hpp" +#include "manager_rest_client.hpp" +#include "scene_loader.hpp" + +#include +#include + +#include +#include + +using namespace tracker; + +// --------------------------------------------------------------------------- +// Fixture: spins up a local httplib::Server on an ephemeral port so the real +// ManagerRestClient can be exercised end-to-end without external services. +// --------------------------------------------------------------------------- +class ManagerRestClientTest : public ::testing::Test { +protected: + void SetUp() override { + Logger::init("warn"); + + server_.Post("/api/v1/auth", [this](const httplib::Request& req, httplib::Response& res) { + auth_handler(req, res); + }); + server_.Get("/api/v1/scenes", [this](const httplib::Request& req, httplib::Response& res) { + scenes_handler(req, res); + }); + + // Listen on ephemeral port on localhost + port_ = server_.bind_to_any_port("127.0.0.1"); + ASSERT_GT(port_, 0) << "Failed to bind to ephemeral port"; + + server_thread_ = std::thread([this] { server_.listen_after_bind(); }); + + // Wait until server is ready + for (int i = 0; i < 50; ++i) { + if (server_.is_running()) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + base_url_ = "http://127.0.0.1:" + std::to_string(port_); + } + + void TearDown() override { + server_.stop(); + if (server_thread_.joinable()) { + server_thread_.join(); + } + Logger::shutdown(); + } + + // Configurable handler callbacks (tests override these) + std::function auth_handler = + [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"token":"test-token-123"})", "application/json"); + }; + + std::function scenes_handler = + [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"results":[]})", "application/json"); + }; + + httplib::Server server_; + std::thread server_thread_; + int port_ = 0; + std::string base_url_; +}; + +// ===== Constructor ===== + +TEST_F(ManagerRestClientTest, ConstructWithUrlOnly) { + ManagerRestClient client(base_url_); + // No crash — just construction +} + +TEST_F(ManagerRestClientTest, ConstructWithCaCertPath) { + ManagerRestClient client(base_url_, "/some/ca.pem"); + // No crash +} + +TEST_F(ManagerRestClientTest, ConstructWithNulloptCaCert) { + ManagerRestClient client(base_url_, std::nullopt); + // No crash +} + +// ===== authenticate() — happy path ===== + +TEST_F(ManagerRestClientTest, AuthenticateSuccess) { + ManagerRestClient client(base_url_); + EXPECT_NO_THROW(client.authenticate("admin", "password")); +} + +TEST_F(ManagerRestClientTest, AuthenticateReceivesCredentials) { + std::string captured_username; + std::string captured_password; + auth_handler = [&](const httplib::Request& req, httplib::Response& res) { + captured_username = req.get_param_value("username"); + captured_password = req.get_param_value("password"); + res.set_content(R"({"token":"tok"})", "application/json"); + }; + + ManagerRestClient client(base_url_); + client.authenticate("myuser", "mypass"); + + EXPECT_EQ(captured_username, "myuser"); + EXPECT_EQ(captured_password, "mypass"); +} + +// ===== authenticate() — error cases ===== + +TEST_F(ManagerRestClientTest, AuthenticateInvalidUrlNoScheme) { + ManagerRestClient client("no-scheme-host"); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateInvalidUrlBadScheme) { + ManagerRestClient client("ftp://host"); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateInvalidUrlEmpty) { + ManagerRestClient client(""); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateConnectionRefused) { + // Bind to an ephemeral port, then close it to guarantee connection refusal + httplib::Server tmp_server; + int closed_port = tmp_server.bind_to_any_port("127.0.0.1"); + ASSERT_GT(closed_port, 0) << "Failed to bind to ephemeral port"; + tmp_server.stop(); + + // Use 50ms timeout to keep the unit test fast (TCP RST is near-instant) + ManagerRestClient client("http://127.0.0.1:" + std::to_string(closed_port), std::nullopt, + std::chrono::milliseconds(50), std::chrono::milliseconds(50)); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateHttpError401) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.status = 401; + res.set_content("Unauthorized", "text/plain"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateHttpError500) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.status = 500; + res.set_content("Internal Server Error", "text/plain"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateResponseNotJson) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.set_content("not json at all", "text/plain"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateResponseJsonArray) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.set_content("[1,2,3]", "application/json"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateResponseMissingTokenField) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"not_token":"abc"})", "application/json"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, AuthenticateResponseTokenNotString) { + auth_handler = [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"token":42})", "application/json"); + }; + + ManagerRestClient client(base_url_); + EXPECT_THROW(client.authenticate("u", "p"), std::runtime_error); +} + +// ===== fetchScenes() — not authenticated ===== + +TEST_F(ManagerRestClientTest, FetchScenesWithoutAuthThrows) { + ManagerRestClient client(base_url_); + EXPECT_THROW(client.fetchScenes(), std::runtime_error); +} + +// ===== fetchScenes() — happy path ===== + +TEST_F(ManagerRestClientTest, FetchScenesSuccess) { + scenes_handler = [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"results":[{"name":"scene1"}]})", "application/json"); + }; + + ManagerRestClient client(base_url_); + client.authenticate("u", "p"); + std::string body = client.fetchScenes(); + EXPECT_NE(body.find("scene1"), std::string::npos); +} + +TEST_F(ManagerRestClientTest, FetchScenesPassesAuthHeader) { + std::string captured_auth; + scenes_handler = [&](const httplib::Request& req, httplib::Response& res) { + captured_auth = req.get_header_value("Authorization"); + res.set_content("{}", "application/json"); + }; + + ManagerRestClient client(base_url_); + client.authenticate("u", "p"); + client.fetchScenes(); + + EXPECT_EQ(captured_auth, "Token test-token-123"); +} + +// ===== fetchScenes() — error cases ===== + +TEST_F(ManagerRestClientTest, FetchScenesHttpError403) { + scenes_handler = [](const httplib::Request&, httplib::Response& res) { + res.status = 403; + res.set_content("Forbidden", "text/plain"); + }; + + ManagerRestClient client(base_url_); + client.authenticate("u", "p"); + EXPECT_THROW(client.fetchScenes(), std::runtime_error); +} + +TEST_F(ManagerRestClientTest, FetchScenesConnectionRefused) { + // Authenticate against the real server first + ManagerRestClient client(base_url_); + client.authenticate("u", "p"); + + // Now stop the server so fetchScenes cannot connect + server_.stop(); + if (server_thread_.joinable()) { + server_thread_.join(); + } + + EXPECT_THROW(client.fetchScenes(), std::runtime_error); +} + +// ===== URL parsing edge cases (tested through authenticate) ===== + +TEST_F(ManagerRestClientTest, AuthenticateUrlWithPathPrefix) { + std::string captured_path; + auth_handler = [&](const httplib::Request& req, httplib::Response& res) { + captured_path = req.path; + res.set_content(R"({"token":"tok"})", "application/json"); + }; + + // Re-bind server with a custom prefix handler + // httplib matches exact paths, so we need to register prefix+path + server_.stop(); + if (server_thread_.joinable()) + server_thread_.join(); + + httplib::Server prefix_server; + prefix_server.Post("/prefix/api/v1/auth", + [&](const httplib::Request& req, httplib::Response& res) { + captured_path = req.path; + res.set_content(R"({"token":"tok"})", "application/json"); + }); + int prefix_port = prefix_server.bind_to_any_port("127.0.0.1"); + std::thread t([&] { prefix_server.listen_after_bind(); }); + + for (int i = 0; i < 50; ++i) { + if (prefix_server.is_running()) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + std::string url = "http://127.0.0.1:" + std::to_string(prefix_port) + "/prefix"; + ManagerRestClient client(url); + client.authenticate("u", "p"); + EXPECT_EQ(captured_path, "/prefix/api/v1/auth"); + + prefix_server.stop(); + t.join(); +} + +TEST_F(ManagerRestClientTest, AuthenticateUrlWithTrailingSlash) { + std::string captured_path; + auth_handler = [&](const httplib::Request& req, httplib::Response& res) { + captured_path = req.path; + res.set_content(R"({"token":"tok"})", "application/json"); + }; + + // URL with trailing slash — parse_url should strip it + std::string url = base_url_ + "/"; + ManagerRestClient client(url); + client.authenticate("u", "p"); + EXPECT_EQ(captured_path, "/api/v1/auth"); +} + +TEST_F(ManagerRestClientTest, FetchScenesUrlWithPathPrefix) { + std::string captured_path; + + server_.stop(); + if (server_thread_.joinable()) + server_thread_.join(); + + httplib::Server prefix_server; + prefix_server.Post("/sub/api/v1/auth", [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"token":"tok"})", "application/json"); + }); + prefix_server.Get("/sub/api/v1/scenes", + [&](const httplib::Request& req, httplib::Response& res) { + captured_path = req.path; + res.set_content(R"({"results":[]})", "application/json"); + }); + int prefix_port = prefix_server.bind_to_any_port("127.0.0.1"); + std::thread t([&] { prefix_server.listen_after_bind(); }); + + for (int i = 0; i < 50; ++i) { + if (prefix_server.is_running()) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + std::string url = "http://127.0.0.1:" + std::to_string(prefix_port) + "/sub"; + ManagerRestClient client(url); + client.authenticate("u", "p"); + client.fetchScenes(); + EXPECT_EQ(captured_path, "/sub/api/v1/scenes"); + + prefix_server.stop(); + t.join(); +} + +// ===== HTTPS-related construction (no real TLS server - just verifies no crash) ===== + +TEST_F(ManagerRestClientTest, ConstructWithHttpsUrlAndEmptyCaCert) { + ManagerRestClient client("https://localhost:9999", ""); + // Construction succeeds; actual connection would fail (no server). +} + +TEST_F(ManagerRestClientTest, ConstructWithHttpsUrlAndCaCertPath) { + ManagerRestClient client("https://localhost:9999", "/nonexistent/ca.pem"); + // Construction succeeds; actual connection would fail. +} + +// ===== default_manager_client_factory ===== + +TEST_F(ManagerRestClientTest, DefaultFactoryCreatesManagerRestClient) { + ManagerConfig cfg; + cfg.url = base_url_; + cfg.auth_path = "/unused"; + auto client = default_manager_client_factory(cfg); + ASSERT_NE(client, nullptr); + // Verify it's a real ManagerRestClient by calling authenticate against our server + EXPECT_NO_THROW(client->authenticate("u", "p")); +} diff --git a/tracker/test/unit/message_handler_test.cpp b/tracker/test/unit/message_handler_test.cpp index 9392e8e88..a9ef39ded 100644 --- a/tracker/test/unit/message_handler_test.cpp +++ b/tracker/test/unit/message_handler_test.cpp @@ -762,6 +762,160 @@ TEST_F(MessageHandlerTest, SchemaValidation_GracefulFallbackOnErrors) { }); } +// +// Dynamic mode (database update) tests +// + +// Test that dynamic mode subscribes to database update topic on start +TEST_F(MessageHandlerTest, DynamicMode_SubscribesToDatabaseUpdateTopic) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + + bool callback_called = false; + handler.enableDynamicMode([&callback_called]() { callback_called = true; }); + + // Expect subscription to both camera and database update topics + EXPECT_CALL(*mock_client_, subscribe(std::format(MessageHandler::TOPIC_CAMERA_SUBSCRIBE_PATTERN, + TEST_CAMERA_ID))) + .Times(1); + EXPECT_CALL(*mock_client_, subscribe(std::string(MessageHandler::TOPIC_DATABASE_UPDATE))) + .Times(1); + + handler.start(); +} + +// Test that static mode does NOT subscribe to database update topic +TEST_F(MessageHandlerTest, StaticMode_NoDatabaseUpdateSubscription) { + // Only camera subscription expected, no database update subscription + EXPECT_CALL(*mock_client_, subscribe(std::format(MessageHandler::TOPIC_CAMERA_SUBSCRIBE_PATTERN, + TEST_CAMERA_ID))) + .Times(1); + EXPECT_CALL(*mock_client_, subscribe(std::string(MessageHandler::TOPIC_DATABASE_UPDATE))) + .Times(0); + + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.start(); +} + +// Test dynamic mode with multiple scenes still subscribes to single database update topic +TEST_F(MessageHandlerTest, DynamicMode_SingleDatabaseSubscriptionForMultipleScenes) { + Camera cam1, cam2; + cam1.uid = "cam-a"; + cam1.name = "Camera A"; + cam2.uid = "cam-b"; + cam2.name = "Camera B"; + + Scene scene1; + scene1.uid = "scene-alpha"; + scene1.name = "Scene Alpha"; + scene1.cameras = {cam1}; + + Scene scene2; + scene2.uid = "scene-beta"; + scene2.name = "Scene Beta"; + scene2.cameras = {cam2}; + + SceneRegistry multi_registry; + multi_registry.register_scenes({scene1, scene2}); + + // Allow camera topic subscriptions + EXPECT_CALL(*mock_client_, subscribe(::testing::_)).Times(::testing::AnyNumber()); + // Exactly one database update subscription regardless of scene count + EXPECT_CALL(*mock_client_, subscribe(std::string(MessageHandler::TOPIC_DATABASE_UPDATE))) + .Times(1); + + MessageHandler handler(mock_client_, multi_registry, test_buffer_, test_config_, false); + handler.enableDynamicMode([]() {}); + handler.start(); +} + +// Test that receiving a database update message triggers the shutdown callback +TEST_F(MessageHandlerTest, DynamicMode_DatabaseUpdateTriggersShutdown) { + bool callback_called = false; + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.enableDynamicMode([&callback_called]() { callback_called = true; }); + handler.start(); + + EXPECT_FALSE(callback_called); + + mock_client_->simulateMessage(MessageHandler::TOPIC_DATABASE_UPDATE, "update"); + + EXPECT_TRUE(callback_called); +} + +// Test that database update messages don't increment camera message counters +TEST_F(MessageHandlerTest, DynamicMode_DatabaseUpdateDoesNotIncrementCounters) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.enableDynamicMode([]() {}); + handler.start(); + + mock_client_->simulateMessage(MessageHandler::TOPIC_DATABASE_UPDATE, "update"); + + EXPECT_EQ(handler.getReceivedCount(), 0); + EXPECT_EQ(handler.getRejectedCount(), 0); + EXPECT_EQ(handler.getBufferedCount(), 0); +} + +// Test that camera messages still work normally in dynamic mode +TEST_F(MessageHandlerTest, DynamicMode_CameraMessagesStillWork) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.enableDynamicMode([]() {}); + handler.start(); + + std::string payload = R"({ + "id": "cam1", + "timestamp": "2026-01-27T12:00:00.000Z", + "objects": { + "person": [{"id": 1, "bounding_box_px": {"x": 10, "y": 20, "width": 50, "height": 100}}] + } + })"; + + mock_client_->simulateMessage("scenescape/data/camera/cam1", payload); + + EXPECT_EQ(handler.getReceivedCount(), 1); + EXPECT_EQ(handler.getRejectedCount(), 0); + EXPECT_EQ(handler.getBufferedCount(), 1); +} + +// Test that stop() unsubscribes from database update topic in dynamic mode +TEST_F(MessageHandlerTest, DynamicMode_StopUnsubscribesFromDatabaseUpdateTopic) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.enableDynamicMode([]() {}); + handler.start(); + + // Allow camera topic unsubscriptions (catch-all must precede specific expectation) + EXPECT_CALL(*mock_client_, unsubscribe(::testing::_)).Times(::testing::AnyNumber()); + EXPECT_CALL(*mock_client_, unsubscribe(std::string(MessageHandler::TOPIC_DATABASE_UPDATE))) + .Times(1); + + handler.stop(); +} + +// Test that stop() in static mode does NOT unsubscribe from database update topic +TEST_F(MessageHandlerTest, StaticMode_StopDoesNotUnsubscribeDatabaseUpdate) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.start(); + + // Allow camera topic unsubscriptions (catch-all must precede specific expectation) + EXPECT_CALL(*mock_client_, unsubscribe(::testing::_)).Times(::testing::AnyNumber()); + EXPECT_CALL(*mock_client_, unsubscribe(std::string(MessageHandler::TOPIC_DATABASE_UPDATE))) + .Times(0); + + handler.stop(); +} + +// Test that database update in static mode is treated as camera message (rejected) +TEST_F(MessageHandlerTest, StaticMode_DatabaseUpdateTreatedAsCameraMessage) { + MessageHandler handler(mock_client_, test_registry_, test_buffer_, test_config_, false); + handler.start(); + + // In static mode, database update topic is routed to handleCameraMessage + // which rejects it because it doesn't match camera topic format + mock_client_->simulateMessage(MessageHandler::TOPIC_DATABASE_UPDATE, "update"); + + EXPECT_EQ(handler.getReceivedCount(), 1); + EXPECT_EQ(handler.getRejectedCount(), 1); +} + // // Schema file edge case test with temp directory // diff --git a/tracker/test/unit/utils/mock_manager_rest_client.hpp b/tracker/test/unit/utils/mock_manager_rest_client.hpp new file mode 100644 index 000000000..241feddc2 --- /dev/null +++ b/tracker/test/unit/utils/mock_manager_rest_client.hpp @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "manager_rest_client.hpp" + +#include + +namespace tracker::test { + +/** + * @brief Mock Manager REST client for unit testing. + * + * Provides gmock methods for IManagerRestClient operations, allowing tests + * to verify Manager API interactions without requiring a real server. + */ +class MockManagerRestClient : public IManagerRestClient { +public: + MOCK_METHOD(void, authenticate, (const std::string&, const std::string&), (override)); + MOCK_METHOD(std::string, fetchScenes, (), (override)); +}; + +} // namespace tracker::test