diff --git a/core/database/foxx/api/schema_router.js b/core/database/foxx/api/schema_router.js index 15880f34b..1b0028ed2 100644 --- a/core/database/foxx/api/schema_router.js +++ b/core/database/foxx/api/schema_router.js @@ -209,7 +209,7 @@ router delete sch._key; delete sch._rev; - sch.id = parsed.id + ":" + parsed.ver; + sch.id = parsed.id + ":" + obj.ver; res.send([sch]); }, }); diff --git a/core/server/tests/integration/CMakeLists.txt b/core/server/tests/integration/CMakeLists.txt index e26556b19..6cb3eadd1 100644 --- a/core/server/tests/integration/CMakeLists.txt +++ b/core/server/tests/integration/CMakeLists.txt @@ -11,11 +11,22 @@ set(MOCK_SCHEMA_PORT "4010" set(MOCK_SCHEMA_PULL "true" CACHE STRING "Pull the mock image before starting (set to 'false' for offline/local builds)") +set(ARANGO_IMAGE "savannah.ornl.gov/datafed/arango-foxx:latest" + CACHE STRING "Docker image for the ArangoDB test instance (with Foxx services)") +set(ARANGO_PORT "8529" + CACHE STRING "Host port for the ArangoDB test container") +set(ARANGO_ROOT_PASS "test" + CACHE STRING "Root password for the ArangoDB test instance") +set(ARANGO_PULL "true" + CACHE STRING "Pull the ArangoDB image before starting (set to 'false' for offline/local builds)") +set(DATAFED_TEST_DATABASE_NAME "sdms_test" + CACHE STRING "Database name for integration tests (default: sdms_test)") + # ── Check for Docker (needed by SchemaAPIClient tests) ─────────────────────── find_program(DOCKER_EXECUTABLE docker) if(NOT DOCKER_EXECUTABLE) - message(STATUS "Docker not found — SchemaAPIClient integration tests will be skipped") + message(STATUS "Docker not found — integration tests requiring containers will be skipped") endif() # ── Compile and register all integration tests ────────────────────────────── @@ -23,6 +34,7 @@ endif() foreach(PROG test_SchemaServiceFactory test_SchemaAPIClient + test_SchemaHandler ) file(GLOB ${PROG}_SOURCES ${PROG}*.cpp) add_executable(integration_${PROG} ${${PROG}_SOURCES}) @@ -52,6 +64,10 @@ endforeach(PROG) if(DOCKER_EXECUTABLE) + # ======================================================================== + # Mock Schema API (Prism) fixture — used by SchemaAPIClient + # ======================================================================== + set(_mock_schema_env "MOCK_SCHEMA_IMAGE=${MOCK_SCHEMA_IMAGE}" "MOCK_SCHEMA_PORT=${MOCK_SCHEMA_PORT}" @@ -59,7 +75,7 @@ if(DOCKER_EXECUTABLE) "CONTAINER_NAME=datafed-mock-schema" ) - set(_test_env + set(_mock_schema_test_env "SCHEMA_API_TEST_URL=http://localhost:${MOCK_SCHEMA_PORT}" ) @@ -91,14 +107,74 @@ if(DOCKER_EXECUTABLE) set_tests_properties(integration_test_SchemaAPIClient PROPERTIES FIXTURES_REQUIRED MockSchemaAPI - ENVIRONMENT "${_test_env}" + ENVIRONMENT "${_mock_schema_test_env}" LABELS "integration" ) if(ENABLE_MEMORY_TESTS) set_tests_properties(memory_test_SchemaAPIClient PROPERTIES FIXTURES_REQUIRED MockSchemaAPI - ENVIRONMENT "${_test_env}" + ENVIRONMENT "${_mock_schema_test_env}" + LABELS "integration;memory" + ) + endif() + + # ======================================================================== + # ArangoDB fixture — used by SchemaHandler + # ======================================================================== + + set(_arango_env + "ARANGO_IMAGE=${ARANGO_IMAGE}" + "ARANGO_PORT=${ARANGO_PORT}" + "ARANGO_ROOT_PASS=${ARANGO_ROOT_PASS}" + "ARANGO_CONTAINER=datafed-test-arango" + "ARANGO_PULL=${ARANGO_PULL}" + "DATAFED_PROJECT_ROOT=${DataFed_SOURCE_DIR}" + "DATAFED_DATABASE_NAME=${DATAFED_TEST_DATABASE_NAME}" + ) + set(_arango_test_env + "DATAFED_TEST_ARANGO_URL=http://localhost:${ARANGO_PORT}/_db/${DATAFED_TEST_DATABASE_NAME}/api/${DATAFED_FOXX_MAJOR}/" + "DATAFED_TEST_ARANGO_USER=root" + "DATAFED_TEST_ARANGO_PASS=${ARANGO_ROOT_PASS}" + "DATAFED_DATABASE_NAME=${ARANGO_TEST_DATABASE_NAME}" + ) + + # ── Fixture setup: start ArangoDB container ────────────────────────── + + add_test( + NAME arango_start + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/start_test_arango.sh + ) + set_tests_properties(arango_start PROPERTIES + FIXTURES_SETUP TestArangoDB + ENVIRONMENT "${_arango_env}" + LABELS "fixture;integration" + ) + + # ── Fixture cleanup: stop and remove container ─────────────────────── + + add_test( + NAME arango_stop + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/stop_test_arango.sh + ) + set_tests_properties(arango_stop PROPERTIES + FIXTURES_CLEANUP TestArangoDB + ENVIRONMENT "${_arango_env}" + LABELS "fixture;integration" + ) + + # ── Attach fixture to SchemaHandler tests ──────────────────────────── + + set_tests_properties(integration_test_SchemaHandler PROPERTIES + FIXTURES_REQUIRED TestArangoDB + ENVIRONMENT "${_arango_test_env}" + LABELS "integration" + ) + + if(ENABLE_MEMORY_TESTS) + set_tests_properties(memory_test_SchemaHandler PROPERTIES + FIXTURES_REQUIRED TestArangoDB + ENVIRONMENT "${_arango_test_env}" LABELS "integration;memory" ) endif() diff --git a/core/server/tests/integration/start_test_arango.sh b/core/server/tests/integration/start_test_arango.sh new file mode 100755 index 000000000..8fd5a66aa --- /dev/null +++ b/core/server/tests/integration/start_test_arango.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# start_test_arango.sh — Start (or reuse) ArangoDB and provision Foxx services. +# +# If ArangoDB is already reachable at the target address, the existing instance +# is reused and no container is started. Either way, Foxx services are +# (re)installed via scripts/install_foxx.sh. +# +# Required env: +# DATAFED_PROJECT_ROOT — Path to the DataFed repository root +# ARANGO_IMAGE — Docker image (only required when starting a container) +# +# Optional env: +# ARANGO_PORT — Host port to bind (default: 8529) +# ARANGO_ROOT_PASS — Root password (default: "test") +# ARANGO_CONTAINER — Container name (default: datafed-test-arango) +# ARANGO_PULL — "true" to pull image before starting (default: true) +# DATAFED_DATABASE_NAME — Database name (default: "sdms_test") +set -eu + +PROJECT_ROOT="${DATAFED_PROJECT_ROOT:?DATAFED_PROJECT_ROOT must be set}" +PORT="${ARANGO_PORT:-8529}" +ROOT_PASS="${ARANGO_ROOT_PASS:-test}" +NAME="${ARANGO_CONTAINER:-datafed-test-arango}" +PULL="${ARANGO_PULL:-true}" +DB_NAME="${DATAFED_DATABASE_NAME:-sdms_test}" +MAX_WAIT=60 + +# State file: if we create it, the cleanup step knows to tear down the +# container. If ArangoDB was already running, the file is never created +# and cleanup leaves the instance alone. +STATE_FILE="/tmp/${NAME}.started-by-fixture" +rm -f "${STATE_FILE}" + +# ── Check for an already-running instance ──────────────────────────────────── + +if curl -sf -o /dev/null \ + -u "root:${ROOT_PASS}" \ + "http://localhost:${PORT}/_api/version" 2>/dev/null; then + echo "ArangoDB already reachable on port ${PORT} — reusing existing instance" +else + # ── No instance found — start a container ──────────────────────────────── + IMAGE="${ARANGO_IMAGE:?ARANGO_IMAGE must be set (no running ArangoDB found)}" + + if ! command -v docker &>/dev/null; then + echo "ERROR: docker not found and no ArangoDB running on port ${PORT}" >&2 + exit 1 + fi + + docker rm -f "${NAME}" &>/dev/null || true + + if [ "${PULL}" = "true" ]; then + echo "Pulling ${IMAGE}..." + docker pull "${IMAGE}" + fi + + echo "Starting ArangoDB test instance on port ${PORT}..." + docker run -d \ + --name "${NAME}" \ + -p "${PORT}:8529" \ + -e ARANGO_ROOT_PASSWORD="${ROOT_PASS}" \ + "${IMAGE}" + + # ── Wait for readiness ─────────────────────────────────────────────────── + echo "Waiting for ArangoDB readiness (max ${MAX_WAIT}s)..." + for i in $(seq 1 "${MAX_WAIT}"); do + if curl -sf -o /dev/null \ + -u "root:${ROOT_PASS}" \ + "http://localhost:${PORT}/_api/version" 2>/dev/null; then + echo "ArangoDB ready on port ${PORT} (took ${i}s)" + break + fi + if [ "$i" -eq "${MAX_WAIT}" ]; then + echo "ERROR: ArangoDB did not become ready in ${MAX_WAIT}s" >&2 + echo "--- container logs ---" + docker logs "${NAME}" 2>&1 | tail -50 || true + echo "--- end logs ---" + docker rm -f "${NAME}" &>/dev/null || true + exit 1 + fi + sleep 1 + done + + # Mark that we started the container so cleanup knows to remove it. + touch "${STATE_FILE}" +fi + +# ── Provision Foxx services ────────────────────────────────────────────────── +# +# install_foxx.sh is idempotent — it checks whether the DB/services exist and +# creates or replaces as needed. We pass credentials via env + CLI flags so +# the script finds the instance we just validated above. +echo "Provisioning database and Foxx services..." +export DATAFED_DATABASE_PASSWORD="${ROOT_PASS}" +export DATAFED_DATABASE_HOST="localhost" +export DATAFED_DATABASE_NAME="${DB_NAME}" + +bash "${PROJECT_ROOT}/scripts/install_foxx.sh" \ + -p "${ROOT_PASS}" \ + -u "root" \ + -i "localhost" + +echo "Foxx provisioning complete." + +# ── Clean up stale test data ───────────────────────────────────────────────── +# +# Previous test runs may have left behind schemas (e.g. from crashes, or from +# the earlier :null bug). Remove anything with a test_ prefix so the new run +# starts clean. This runs after Foxx provisioning to ensure the database and +# collections exist. + +ARANGO_URL="http://localhost:${PORT}" + +echo "Cleaning stale test schemas from ${DB_NAME}..." +RESULT=$(curl -sf -u "root:${ROOT_PASS}" \ + "${ARANGO_URL}/_db/${DB_NAME}/_api/cursor" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "FOR s IN sch FILTER STARTS_WITH(s.id, \"test_\") REMOVE s IN sch RETURN OLD.id"}' \ + 2>/dev/null) || true + +if [ -n "${RESULT}" ]; then + REMOVED=$(echo "${RESULT}" | grep -o '"result":\[' | wc -l) + if [ "${REMOVED}" -gt 0 ]; then + COUNT=$(echo "${RESULT}" | grep -o '"id"' | wc -l) + echo "Removed ${COUNT} stale test schema(s)" + else + echo "No stale test schemas found" + fi +else + echo "No stale test schemas found (or sch collection does not exist yet)" +fi + +# Also clean up stale schema version edges that reference removed schemas +echo "Cleaning stale schema version edges..." +curl -sf -u "root:${ROOT_PASS}" \ + "${ARANGO_URL}/_db/${DB_NAME}/_api/cursor" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "FOR e IN sch_ver LET fromExists = LENGTH(FOR s IN sch FILTER s._id == e._from LIMIT 1 RETURN 1) LET toExists = LENGTH(FOR s IN sch FILTER s._id == e._to LIMIT 1 RETURN 1) FILTER fromExists == 0 OR toExists == 0 REMOVE e IN sch_ver RETURN OLD._key"}' \ + -o /dev/null 2>/dev/null || true + +# Clean up stale schema dependency edges +echo "Cleaning stale schema dependency edges..." +curl -sf -u "root:${ROOT_PASS}" \ + "${ARANGO_URL}/_db/${DB_NAME}/_api/cursor" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"query": "FOR e IN sch_dep LET fromExists = LENGTH(FOR s IN sch FILTER s._id == e._from LIMIT 1 RETURN 1) LET toExists = LENGTH(FOR s IN sch FILTER s._id == e._to LIMIT 1 RETURN 1) FILTER fromExists == 0 OR toExists == 0 REMOVE e IN sch_dep RETURN OLD._key"}' \ + -o /dev/null 2>/dev/null || true + +echo "Test environment ready." diff --git a/core/server/tests/integration/stop_test_arango.sh b/core/server/tests/integration/stop_test_arango.sh new file mode 100755 index 000000000..0b55ef910 --- /dev/null +++ b/core/server/tests/integration/stop_test_arango.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# stop_test_arango.sh — Stop and remove the ArangoDB test container, but only +# if the fixture started it. +# +# If start_test_arango.sh reused a pre-existing instance, the state file won't +# exist and this script is a no-op. +# +# Optional env: +# ARANGO_CONTAINER — Container name (default: datafed-test-arango) +set -u + +NAME="${ARANGO_CONTAINER:-datafed-test-arango}" +STATE_FILE="/tmp/${NAME}.started-by-fixture" + +if [ -f "${STATE_FILE}" ]; then + if docker rm -f "${NAME}" &>/dev/null; then + echo "Stopped and removed container: ${NAME}" + else + echo "Container ${NAME} was not running (already removed?)" + fi + rm -f "${STATE_FILE}" +else + echo "ArangoDB was pre-existing — leaving it alone" +fi + +exit 0 diff --git a/core/server/tests/integration/test_SchemaHandler.cpp b/core/server/tests/integration/test_SchemaHandler.cpp new file mode 100644 index 000000000..7a11e8425 --- /dev/null +++ b/core/server/tests/integration/test_SchemaHandler.cpp @@ -0,0 +1,786 @@ +#define BOOST_TEST_MAIN +#define BOOST_TEST_MODULE SchemaHandlerIntegration +#include + +// Local includes +#include "client_handlers/SchemaHandler.hpp" +#include "DatabaseAPI.hpp" +#include "common/TraceException.hpp" + +// Standard includes +#include +#include +#include +#include +#include +#include + +using namespace SDMS::Core; +using namespace SDMS; + +// ============================================================================ +// Fixture +// +// Connects to a live ArangoDB instance with Foxx services installed. +// +// Required environment variables: +// DATAFED_TEST_ARANGO_URL — e.g. "http://localhost:8529" +// DATAFED_TEST_ARANGO_USER — e.g. "root" +// DATAFED_TEST_ARANGO_PASS — password +// +// Optional: +// DATAFED_TEST_ARANGO_DB — database name (default: "sdms") +// +// If env vars are missing, all tests in this file are skipped (not failed). +// +// Each test case uses the fixture's createTestSchema / cleanup helpers +// to avoid test interdependencies. +// ============================================================================ + +namespace { + +const std::string TEST_USER = "u/integration_test_user"; + +const std::string VALID_SCHEMA_DEF = R"({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "number" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["name", "value"] +})"; + +const std::string UPDATED_SCHEMA_DEF = R"({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "number" }, + "unit": { "type": "string" }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["name", "value"] +})"; + +const std::string VALID_METADATA = R"({ + "name": "widget", + "value": 42.0, + "tags": ["alpha", "beta"] +})"; + +const std::string INVALID_METADATA_MISSING_REQUIRED = R"({ + "value": 42.0 +})"; + +const std::string INVALID_METADATA_WRONG_TYPE = R"({ + "name": "widget", + "value": "not_a_number" +})"; + +const std::string INVALID_JSON = R"({not valid json at all)"; + +const std::string SCHEMA_MISSING_TYPE = R"({ + "properties": { "x": { "type": "string" } } +})"; + +const std::string SCHEMA_MISSING_PROPERTIES = R"({ "type": "object" })"; + +} // anonymous namespace + +struct ArangoFixture { + std::unique_ptr db; + std::unique_ptr handler; + LogContext log_context; + bool available; + + // Track created schema IDs for cleanup + std::vector created_schemas; + + ArangoFixture() : available(false) { + std::string url = getEnv("DATAFED_TEST_ARANGO_URL"); + std::string user = getEnv("DATAFED_TEST_ARANGO_USER"); + std::string pass = getEnv("DATAFED_TEST_ARANGO_PASS"); + + if (url.empty() || user.empty()) { + return; + } + + log_context.thread_name = "schema-handler-integration"; + log_context.thread_id = 0; + log_context.correlation_id = "schema-handler-test"; + + try { + db = std::make_unique(url, user, pass); + + // Create the test user (ignore errors if it already exists) + try { + UserCreateRequest user_req; + UserDataReply user_reply; + user_req.set_uid("integration_test_user"); + user_req.set_name("Integration Test"); + user_req.set_email("test@test.com"); + db->userCreate(user_req, user_reply, log_context); + } catch (...) { + // User may already exist from a previous run — that's fine + } + + handler = std::make_unique(*db); + available = true; + } catch (std::exception &e) { + BOOST_TEST_MESSAGE("ArangoDB connection failed: " << e.what()); + } + } + + ~ArangoFixture() { cleanup(); } + + void cleanup() { + if (!available || !handler) + return; + + for (auto it = created_schemas.rbegin(); it != created_schemas.rend(); + ++it) { + try { + SchemaDeleteRequest req; + AckReply reply; + req.set_id(*it); + handler->handleDelete(TEST_USER, req, reply, log_context); + } catch (...) { + // Best-effort cleanup — don't mask test failures + } + } + created_schemas.clear(); + } + + /// Create a schema and track it for cleanup. Returns the schema ID. + std::string createTestSchema(const std::string &name, + const std::string &def = VALID_SCHEMA_DEF, + const std::string &desc = "integration test") { + SchemaCreateRequest req; + SchemaDataReply reply; + + req.set_id(name); + req.set_def(def); + req.set_desc(desc); + req.set_pub(false); + req.set_sys(false); + req.set_type("json-schema"); + req.set_format("json"); + + handler->handleCreate(TEST_USER, req, reply, log_context); + + std::string id = reply.schema(0).id(); + created_schemas.push_back(id); + return id; + } + + bool isAvailable() const { return available; } + + static std::string getEnv(const char *name) { + const char *val = std::getenv(name); + if (!val || std::string(val).empty()) + return ""; + return val; + } + + /// Poll until ArangoSearch view catches up, or timeout. + SchemaDataReply waitForSearchResults(const std::string &id_prefix, + int min_expected, + int max_attempts = 20, + int interval_ms = 250) { + SchemaSearchRequest search_req; + SchemaDataReply search_reply; + search_req.set_id(id_prefix); + + for (int attempt = 0; attempt < max_attempts; ++attempt) { + search_reply.Clear(); + handler->handleSearch(TEST_USER, search_req, search_reply, log_context); + + if (search_reply.schema_size() >= min_expected) { + return search_reply; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); + } + + // Return whatever we got — the caller's BOOST_TEST will report the failure + return search_reply; + } + +}; + +#define REQUIRE_AVAILABLE() \ + BOOST_REQUIRE_MESSAGE(isAvailable(), \ + "Test ArangoDB not configured — set DATAFED_TEST_ARANGO_URL, " \ + "DATAFED_TEST_ARANGO_USER, and DATAFED_TEST_ARANGO_PASS") +// ============================================================================ +// Test Suite: handleCreate +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(HandleCreate, ArangoFixture) + +BOOST_AUTO_TEST_CASE(create_valid_schema) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_create_valid"); + + // ID should be versioned: name:version + BOOST_TEST(id.find("test_create_valid:") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(create_and_view_round_trip) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_create_view", VALID_SCHEMA_DEF, + "round trip test"); + + // View it back + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(view_reply.schema(0).id() == id); + BOOST_TEST(view_reply.schema(0).desc() == "round trip test"); + + // def should contain the schema definition + std::string returned_def = view_reply.schema(0).def(); + BOOST_TEST(!returned_def.empty()); + + // Parse and verify structure + nlohmann::json parsed = nlohmann::json::parse(returned_def); + BOOST_TEST(parsed.contains("properties")); + BOOST_TEST(parsed["type"].get() == "object"); +} + +BOOST_AUTO_TEST_CASE(create_with_invalid_json_throws) { + REQUIRE_AVAILABLE(); + + SchemaCreateRequest req; + SchemaDataReply reply; + + req.set_id("test_bad_json"); + req.set_def(INVALID_JSON); + req.set_desc("should fail"); + req.set_pub(false); + req.set_sys(false); + req.set_type("json-schema"); + req.set_format("json"); + + BOOST_CHECK_THROW( + handler->handleCreate(TEST_USER, req, reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_CASE(create_with_missing_properties_throws) { + REQUIRE_AVAILABLE(); + + SchemaCreateRequest req; + SchemaDataReply reply; + + req.set_id("test_no_props"); + req.set_def(SCHEMA_MISSING_PROPERTIES); + req.set_desc("should fail"); + req.set_pub(false); + req.set_sys(false); + req.set_type("json-schema"); + req.set_format("json"); + + BOOST_CHECK_THROW( + handler->handleCreate(TEST_USER, req, reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_CASE(create_with_missing_type_throws) { + REQUIRE_AVAILABLE(); + + SchemaCreateRequest req; + SchemaDataReply reply; + + req.set_id("test_no_type"); + req.set_def(SCHEMA_MISSING_TYPE); + req.set_desc("should fail"); + req.set_pub(false); + req.set_sys(false); + req.set_type("json-schema"); + req.set_format("json"); + + BOOST_CHECK_THROW( + handler->handleCreate(TEST_USER, req, reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_CASE(create_with_description) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_create_desc", VALID_SCHEMA_DEF, + "detailed description here"); + + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(view_reply.schema(0).desc() == "detailed description here"); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: handleRevise +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(HandleRevise, ArangoFixture) + +BOOST_AUTO_TEST_CASE(revise_creates_new_version) { + REQUIRE_AVAILABLE(); + + std::string id_v1 = createTestSchema("test_revise"); + + // Revise with updated definition + SchemaReviseRequest rev_req; + SchemaDataReply rev_reply; + rev_req.set_id(id_v1); + rev_req.set_def(UPDATED_SCHEMA_DEF); + rev_req.set_desc("revised version"); + + handler->handleRevise(TEST_USER, rev_req, rev_reply, log_context); + + BOOST_REQUIRE(rev_reply.schema_size() > 0); + std::string id_v2 = rev_reply.schema(0).id(); + created_schemas.push_back(id_v2); + + // New ID should differ from original + BOOST_TEST(id_v2 != id_v1); + + // View the new revision + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id_v2); + + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(view_reply.schema(0).desc() == "revised version"); + + // Verify the new definition has the added field + nlohmann::json parsed = + nlohmann::json::parse(view_reply.schema(0).def()); + BOOST_TEST(parsed["properties"].contains("unit")); +} + +BOOST_AUTO_TEST_CASE(revise_without_def_skips_validation) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_revise_no_def"); + + // Revise with no def — should not throw from validation + SchemaReviseRequest rev_req; + SchemaDataReply rev_reply; + rev_req.set_id(id); + // Deliberately NOT setting def + + // May throw from DB (revision logic) but must NOT throw from validation + try { + handler->handleRevise(TEST_USER, rev_req, rev_reply, log_context); + if (rev_reply.schema_size() > 0) { + created_schemas.push_back(rev_reply.schema(0).id()); + } + } catch (TraceException &e) { + std::string msg = e.toString(); + BOOST_TEST(msg.find("Invalid metadata schema") == std::string::npos); + BOOST_TEST(msg.find("Schema validation failed") == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(revise_with_invalid_def_throws) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_revise_bad_def"); + + SchemaReviseRequest rev_req; + SchemaDataReply rev_reply; + rev_req.set_id(id); + rev_req.set_def(INVALID_JSON); + + BOOST_CHECK_THROW( + handler->handleRevise(TEST_USER, rev_req, rev_reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: handleUpdate +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(HandleUpdate, ArangoFixture) + +BOOST_AUTO_TEST_CASE(update_description_only) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_update_desc", VALID_SCHEMA_DEF, + "original description"); + + SchemaUpdateRequest upd_req; + SchemaDataReply upd_reply; + upd_req.set_id(id); + upd_req.set_desc("updated description"); + + handler->handleUpdate(TEST_USER, upd_req, upd_reply, log_context); + + // View and verify + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(view_reply.schema(0).desc() == "updated description"); +} + +BOOST_AUTO_TEST_CASE(update_definition) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_update_def"); + + SchemaUpdateRequest upd_req; + SchemaDataReply upd_reply; + upd_req.set_id(id); + upd_req.set_def(UPDATED_SCHEMA_DEF); + + handler->handleUpdate(TEST_USER, upd_req, upd_reply, log_context); + + // View and verify the new def + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + nlohmann::json parsed = + nlohmann::json::parse(view_reply.schema(0).def()); + BOOST_TEST(parsed["properties"].contains("unit")); +} + +BOOST_AUTO_TEST_CASE(update_without_def_skips_validation) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_update_no_def"); + + SchemaUpdateRequest upd_req; + SchemaDataReply upd_reply; + upd_req.set_id(id); + // Deliberately NOT setting def — only description + upd_req.set_desc("description only update"); + + // Must NOT throw from validation + try { + handler->handleUpdate(TEST_USER, upd_req, upd_reply, log_context); + } catch (TraceException &e) { + std::string msg = e.toString(); + BOOST_TEST(msg.find("Invalid metadata schema") == std::string::npos); + BOOST_TEST(msg.find("Schema validation failed") == std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(update_with_invalid_def_throws) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_update_bad_def"); + + SchemaUpdateRequest upd_req; + SchemaDataReply upd_reply; + upd_req.set_id(id); + upd_req.set_def(INVALID_JSON); + + BOOST_CHECK_THROW( + handler->handleUpdate(TEST_USER, upd_req, upd_reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: handleDelete +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(HandleDelete, ArangoFixture) + +BOOST_AUTO_TEST_CASE(delete_existing_schema) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_delete"); + + // Remove from cleanup list — we're manually deleting + created_schemas.pop_back(); + + SchemaDeleteRequest del_req; + AckReply del_reply; + del_req.set_id(id); + + BOOST_CHECK_NO_THROW( + handler->handleDelete(TEST_USER, del_req, del_reply, log_context)); + + // Verify it's gone — view should throw + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + + BOOST_CHECK_THROW( + handler->handleView(TEST_USER, view_req, view_reply, log_context), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: handleSearch +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(HandleSearch, ArangoFixture) + +BOOST_AUTO_TEST_CASE(search_finds_created_schemas) { + REQUIRE_AVAILABLE(); + + createTestSchema("test_search_a"); + createTestSchema("test_search_b"); + createTestSchema("test_search_c"); + + SchemaDataReply search_reply = waitForSearchResults("test_search_", 3); + + BOOST_TEST(search_reply.schema_size() >= 3); +} + +BOOST_AUTO_TEST_CASE(search_returns_def_content) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_viewable_schema"); + + SchemaDataReply search_reply = waitForSearchResults("test_viewable_schema", 1); + + BOOST_REQUIRE(search_reply.schema_size() > 0); + + // Search results are lightweight — def is not included. + // Use the ID we already know from create to fetch via view. + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(!view_reply.schema(0).def().empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: Metadata Validation +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(MetadataValidation, ArangoFixture) + +BOOST_AUTO_TEST_CASE(valid_metadata_passes) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_validate_pass"); + + std::string errors = handler->validateMetadataContent( + id, VALID_METADATA, log_context); + + BOOST_TEST(errors.empty()); +} + +BOOST_AUTO_TEST_CASE(missing_required_field_fails) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_validate_missing"); + + std::string errors = handler->validateMetadataContent( + id, INVALID_METADATA_MISSING_REQUIRED, log_context); + + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(wrong_type_fails) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_validate_type"); + + std::string errors = handler->validateMetadataContent( + id, INVALID_METADATA_WRONG_TYPE, log_context); + + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(invalid_json_metadata_fails) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_validate_bad_json"); + + std::string errors = handler->validateMetadataContent( + id, INVALID_JSON, log_context); + + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(nonexistent_schema_returns_error) { + REQUIRE_AVAILABLE(); + + std::string errors = handler->validateMetadataContent( + "does_not_exist:99", VALID_METADATA, log_context); + + // Should return an error string, not throw + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(handle_metadata_validate_sets_errors_on_failure) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_handle_validate_fail"); + + MetadataValidateRequest req; + MetadataValidateReply reply; + req.set_sch_id(id); + req.set_metadata(INVALID_METADATA_MISSING_REQUIRED); + + handler->handleMetadataValidate(TEST_USER, req, reply, log_context); + + BOOST_TEST(!reply.errors().empty()); +} + +BOOST_AUTO_TEST_CASE(handle_metadata_validate_no_errors_on_success) { + REQUIRE_AVAILABLE(); + + std::string id = createTestSchema("test_handle_validate_pass"); + + MetadataValidateRequest req; + MetadataValidateReply reply; + req.set_sch_id(id); + req.set_metadata(VALID_METADATA); + + handler->handleMetadataValidate(TEST_USER, req, reply, log_context); + + BOOST_TEST(reply.errors().empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: Full Lifecycle +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(FullLifecycle, ArangoFixture) + +BOOST_AUTO_TEST_CASE(create_validate_update_revise_delete) { + REQUIRE_AVAILABLE(); + + // 1. Create + std::string id = createTestSchema("test_lifecycle", VALID_SCHEMA_DEF, + "lifecycle test v1"); + + // 2. Validate metadata against it + { + std::string errors = handler->validateMetadataContent( + id, VALID_METADATA, log_context); + BOOST_TEST(errors.empty()); + } + + // 3. Validate bad metadata + { + std::string errors = handler->validateMetadataContent( + id, INVALID_METADATA_MISSING_REQUIRED, log_context); + BOOST_TEST(!errors.empty()); + } + + // 4. Update description + { + SchemaUpdateRequest upd_req; + SchemaDataReply upd_reply; + upd_req.set_id(id); + upd_req.set_desc("lifecycle test v1 updated"); + + handler->handleUpdate(TEST_USER, upd_req, upd_reply, log_context); + } + + // 5. View and verify update + { + SchemaViewRequest view_req; + SchemaDataReply view_reply; + view_req.set_id(id); + handler->handleView(TEST_USER, view_req, view_reply, log_context); + + BOOST_REQUIRE(view_reply.schema_size() > 0); + BOOST_TEST(view_reply.schema(0).desc() == "lifecycle test v1 updated"); + } + + // 6. Revise with new definition + std::string id_v2; + { + SchemaReviseRequest rev_req; + SchemaDataReply rev_reply; + rev_req.set_id(id); + rev_req.set_def(UPDATED_SCHEMA_DEF); + rev_req.set_desc("lifecycle test v2"); + + handler->handleRevise(TEST_USER, rev_req, rev_reply, log_context); + + BOOST_REQUIRE(rev_reply.schema_size() > 0); + id_v2 = rev_reply.schema(0).id(); + created_schemas.push_back(id_v2); + + BOOST_TEST(id_v2 != id); + } + + // 7. Validate metadata against new revision + { + std::string errors = handler->validateMetadataContent( + id_v2, VALID_METADATA, log_context); + BOOST_TEST(errors.empty()); + } + + // 8. Delete both versions + { + // Remove from cleanup — we're deleting manually + created_schemas.clear(); + + SchemaDeleteRequest del_req; + AckReply del_reply; + + del_req.set_id(id_v2); + BOOST_CHECK_NO_THROW( + handler->handleDelete(TEST_USER, del_req, del_reply, log_context)); + + del_req.set_id(id); + BOOST_CHECK_NO_THROW( + handler->handleDelete(TEST_USER, del_req, del_reply, log_context)); + } + + // 9. Verify both are gone + { + SchemaViewRequest view_req; + SchemaDataReply view_reply; + + view_req.set_id(id); + BOOST_CHECK_THROW( + handler->handleView(TEST_USER, view_req, view_reply, log_context), + TraceException); + + view_req.set_id(id_v2); + BOOST_CHECK_THROW( + handler->handleView(TEST_USER, view_req, view_reply, log_context), + TraceException); + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/scripts/compose_build_images.sh b/scripts/compose_build_images.sh index 1536367b7..ee771de5f 100755 --- a/scripts/compose_build_images.sh +++ b/scripts/compose_build_images.sh @@ -73,6 +73,13 @@ if [[ "$BUILD_METADATA" == "TRUE" ]]; then --build-arg BASE_IMAGE=$BASE_IMAGE \ -t datafed-runtime:latest fi + docker build -f \ + "${PROJECT_ROOT}/core/docker/Dockerfile" \ + --build-arg DEPENDENCIES="datafed-dependencies:latest" \ + --build-arg RUNTIME="datafed-runtime:latest" \ + --target core-build \ + "${PROJECT_ROOT}" \ + -t datafed-core-build:latest docker build -f \ "${PROJECT_ROOT}/core/docker/Dockerfile" \ --build-arg DEPENDENCIES="datafed-dependencies:latest" \ diff --git a/tests/mock/CMakeLists.txt b/tests/mock/CMakeLists.txt index 038f16943..23dd87b3a 100644 --- a/tests/mock/CMakeLists.txt +++ b/tests/mock/CMakeLists.txt @@ -8,7 +8,7 @@ foreach(PROG file(GLOB ${PROG}_SOURCES ${PROG}*.cpp) add_executable(mock_liveness_${PROG} ${${PROG}_SOURCES}) - target_link_libraries(mock_liveness_${PROG} PUBLIC ${DATAFED_BOOST_LIBRARIES} ${DATAFED_GSSAPI_LIBRARIES} ${DATAFED_GLOBUS_COMMON_LIBRARIES} common ) + target_link_libraries(mock_liveness_${PROG} PUBLIC ${DATAFED_BOOST_LIBRARIES} common ) if(BUILD_SHARED_LIBS) target_compile_definitions(mock_liveness_${PROG} PRIVATE BOOST_TEST_DYN_LINK) endif() diff --git a/web/static/dlg_schema.js b/web/static/dlg_schema.js index ee96b6ad1..da40f6807 100644 --- a/web/static/dlg_schema.js +++ b/web/static/dlg_schema.js @@ -11,13 +11,22 @@ export const mode_rev = 3; const dlg_title = ["View", "Edit", "Create New", "Create Revision of "]; const btn_title = ["Close", "Save", "Create", "Create"]; -/** Strip version suffix from a composite schema ID (e.g. "foo:0" -> "foo") */ +/** + * Strip version suffix from a composite schema ID (e.g. "foo:0" -> "foo"). + * @param {string} id - Composite schema ID. + * @returns {string} Schema name without version suffix. + */ function schemaName(id) { var idx = id.indexOf(":"); return idx !== -1 ? id.substring(0, idx) : id; } -/** Ensure a schema ID has the :version suffix */ +/** + * Ensure a schema ID has the :version suffix. + * @param {string} id - Schema ID, possibly without version. + * @param {string|number} ver - Version to append if missing. + * @returns {string} Schema ID with version suffix. + */ function schemaId(id, ver) { return id.indexOf(":") !== -1 ? id : id + ":" + ver; } diff --git a/web/static/dlg_schema_list.js b/web/static/dlg_schema_list.js index e3fa5c11f..a5e7fa123 100644 --- a/web/static/dlg_schema_list.js +++ b/web/static/dlg_schema_list.js @@ -8,13 +8,22 @@ import * as dlgSchema from "./dlg_schema.js"; var tree, dlg_inst, frame; -/** Strip version suffix from a composite schema ID (e.g. "foo:0" -> "foo") */ +/** + * Strip version suffix from a composite schema ID (e.g. "foo:0" -> "foo"). + * @param {string} id - Composite schema ID. + * @returns {string} Schema name without version suffix. + */ function schemaName(id) { var idx = id.indexOf(":"); return idx !== -1 ? id.substring(0, idx) : id; } -/** Ensure a schema ID has the :version suffix */ +/** + * Ensure a schema ID has the :version suffix. + * @param {string} id - Schema ID, possibly without version. + * @param {string|number} ver - Version to append if missing. + * @returns {string} Schema ID with version suffix. + */ function schemaId(id, ver) { return id.indexOf(":") !== -1 ? id : id + ":" + ver; }