diff --git a/core/database/foxx/api/schema_router.js b/core/database/foxx/api/schema_router.js index 0b6796dcc..8f7dbcc02 100644 --- a/core/database/foxx/api/schema_router.js +++ b/core/database/foxx/api/schema_router.js @@ -58,7 +58,7 @@ function parseSchemaId(schId) { } if (colonCount === 0) { - return { id: schId, ver: 0 }; + return { id: schId, ver: null }; } const idx = schId.indexOf(":"); diff --git a/core/server/ISchemaStorage.hpp b/core/server/ISchemaStorage.hpp index 8efed3675..77e0671cc 100644 --- a/core/server/ISchemaStorage.hpp +++ b/core/server/ISchemaStorage.hpp @@ -55,16 +55,23 @@ class ISchemaStorage { /** * @brief Store schema content on create. * - * @param a_id Schema ID (matches Arango document ID). - * @param a_content The raw schema definition text. - * @param a_desc Human-readable description (storage may use this). - * @param log_context Logging/correlation context. + * @param a_id Schema ID (matches Arango document ID). + * @param a_content The raw schema definition text. + * @param a_desc Human-readable description (storage may use this). + * @param a_schema_format Serialization format ("json", "yaml", "xml"). + * @param a_engine Validation engine / schema type ("JSONSchema", + * "LinkML", etc.). + * @param a_version Semantic version string (empty if not versioned). + * @param log_context Logging/correlation context. * @return The value to write into Arango's `def` field. * @throws TraceException on failure. */ virtual std::string storeContent(const std::string &a_id, const std::string &a_content, const std::string &a_desc, + const std::string &a_schema_format, + const std::string &a_engine, + const std::string &a_version, LogContext log_context) = 0; /** @@ -83,16 +90,22 @@ class ISchemaStorage { /** * @brief Update schema content. * - * @param a_id Schema ID. - * @param a_content New schema definition text. - * @param a_desc Updated description (nullopt if unchanged). - * @param log_context Logging/correlation context. + * @param a_id Schema ID. + * @param a_content New schema definition text. + * @param a_desc Updated description (nullopt if unchanged). + * @param a_schema_format Updated format (nullopt if unchanged). + * @param a_engine Updated engine (nullopt if unchanged). + * @param a_version Updated version (nullopt if unchanged). + * @param log_context Logging/correlation context. * @return The value to write into Arango's `def` field. * @throws TraceException on failure. */ virtual std::string updateContent(const std::string &a_id, const std::string &a_content, const std::optional &a_desc, + const std::optional &a_schema_format, + const std::optional &a_engine, + const std::optional &a_version, LogContext log_context) = 0; /** diff --git a/core/server/SchemaAPIClient.cpp b/core/server/SchemaAPIClient.cpp index f75bc29d9..abcbc1a60 100644 --- a/core/server/SchemaAPIClient.cpp +++ b/core/server/SchemaAPIClient.cpp @@ -41,7 +41,8 @@ SchemaAPIClient::SchemaAPIClient(const SchemaAPIConfig &a_config) curl_easy_setopt(m_curl, CURLOPT_CAINFO, m_config.ca_cert_path.c_str()); // ── Timeouts ────────────────────────────────────────────────────────── - curl_easy_setopt(m_curl, CURLOPT_CONNECTTIMEOUT, m_config.connect_timeout_sec); + curl_easy_setopt(m_curl, CURLOPT_CONNECTTIMEOUT, + m_config.connect_timeout_sec); curl_easy_setopt(m_curl, CURLOPT_TIMEOUT, m_config.request_timeout_sec); } @@ -50,6 +51,15 @@ SchemaAPIClient::~SchemaAPIClient() { curl_easy_cleanup(m_curl); } +// ── Custom Headers ────────────────────────────────────────────────────────── + +void SchemaAPIClient::setCustomHeaders( + const std::map &a_headers) { + m_custom_headers = a_headers; +} + +void SchemaAPIClient::clearCustomHeaders() { m_custom_headers.clear(); } + // ── Low-level CURL ────────────────────────────────────────────────────────── nlohmann::json SchemaAPIClient::curlPerform(const std::string &a_method, @@ -74,13 +84,20 @@ nlohmann::json SchemaAPIClient::curlPerform(const std::string &a_method, headers = curl_slist_append(headers, "Accept: application/json"); if (m_config.hasAuth()) { - std::string auth = "Authorization: Bearer " + m_config.bearer_token; - headers = curl_slist_append(headers, auth.c_str()); + std::string auth = "Authorization: Bearer " + m_config.bearer_token; + headers = curl_slist_append(headers, auth.c_str()); } std::string corr_header = "x-correlation-id: " + log_context.correlation_id; headers = curl_slist_append(headers, corr_header.c_str()); + // Append any custom headers (used by integration tests for Prism's + // Prefer header, etc.) + for (const auto &[name, value] : m_custom_headers) { + std::string h = name + ": " + value; + headers = curl_slist_append(headers, h.c_str()); + } + curl_easy_setopt(m_curl, CURLOPT_HTTPHEADER, headers); if (a_body && !a_body->empty()) { @@ -125,8 +142,8 @@ nlohmann::json SchemaAPIClient::curlPerform(const std::string &a_method, nlohmann::json SchemaAPIClient::httpGet(const std::string &a_path, LogContext log_context) { long code = 0; - auto result = - curlPerform("GET", m_config.base_url + a_path, nullptr, code, log_context); + auto result = curlPerform("GET", m_config.base_url + a_path, nullptr, code, + log_context); if (code == 404) EXCEPT_PARAM(BAD_REQUEST, "SchemaAPI: not found: " << a_path); if (code < 200 || code >= 300) @@ -139,8 +156,8 @@ nlohmann::json SchemaAPIClient::httpPost(const std::string &a_path, long &a_http_code, LogContext log_context) { std::string body_str = a_body.dump(); - return curlPerform("POST", m_config.base_url + a_path, &body_str, a_http_code, - log_context); + return curlPerform("POST", m_config.base_url + a_path, &body_str, + a_http_code, log_context); } nlohmann::json SchemaAPIClient::httpPut(const std::string &a_path, @@ -148,8 +165,8 @@ nlohmann::json SchemaAPIClient::httpPut(const std::string &a_path, LogContext log_context) { long code = 0; std::string body_str = a_body.dump(); - auto result = - curlPerform("PUT", m_config.base_url + a_path, &body_str, code, log_context); + auto result = curlPerform("PUT", m_config.base_url + a_path, &body_str, code, + log_context); if (code < 200 || code >= 300) EXCEPT_PARAM(SERVICE_ERROR, "SchemaAPI PUT failed, HTTP " << code); return result; @@ -160,8 +177,8 @@ nlohmann::json SchemaAPIClient::httpPatch(const std::string &a_path, LogContext log_context) { long code = 0; std::string body_str = a_body.dump(); - auto result = - curlPerform("PATCH", m_config.base_url + a_path, &body_str, code, log_context); + auto result = curlPerform("PATCH", m_config.base_url + a_path, &body_str, + code, log_context); if (code == 404) EXCEPT_PARAM(BAD_REQUEST, "SchemaAPI: not found for PATCH"); if (code < 200 || code >= 300) @@ -172,7 +189,8 @@ nlohmann::json SchemaAPIClient::httpPatch(const std::string &a_path, void SchemaAPIClient::httpDelete(const std::string &a_path, LogContext log_context) { long code = 0; - curlPerform("DELETE", m_config.base_url + a_path, nullptr, code, log_context); + curlPerform("DELETE", m_config.base_url + a_path, nullptr, code, + log_context); if (code != 204 && code != 404) EXCEPT_PARAM(SERVICE_ERROR, "SchemaAPI DELETE failed, HTTP " << code); } @@ -182,31 +200,48 @@ void SchemaAPIClient::httpDelete(const std::string &a_path, void SchemaAPIClient::putSchema(const std::string &a_id, const std::string &a_name, const std::string &a_description, + const std::string &a_schema_format, + const std::string &a_engine, const std::string &a_content, + const std::string &a_version, LogContext log_context) { nlohmann::json body; - body["id"] = a_id; + // Required fields per SchemaReplace body["name"] = a_name; + body["schema_format"] = a_schema_format; + body["engine"] = a_engine; body["content"] = a_content; + + // Optional fields — only include when non-empty if (!a_description.empty()) body["description"] = a_description; + if (!a_version.empty()) + body["version"] = a_version; httpPut("/schemas/" + a_id, body, log_context); } -void SchemaAPIClient::patchSchema(const std::string &a_id, - const std::optional &a_name, - const std::optional &a_description, - const std::optional &a_content, - LogContext log_context) { - nlohmann::json body; - body["id"] = a_id; +void SchemaAPIClient::patchSchema( + const std::string &a_id, const std::optional &a_name, + const std::optional &a_description, + const std::optional &a_schema_format, + const std::optional &a_engine, + const std::optional &a_content, + const std::optional &a_version, LogContext log_context) { + nlohmann::json body = nlohmann::json::object(); + if (a_name) body["name"] = *a_name; if (a_description) body["description"] = *a_description; + if (a_schema_format) + body["schema_format"] = *a_schema_format; + if (a_engine) + body["engine"] = *a_engine; if (a_content) body["content"] = *a_content; + if (a_version) + body["version"] = *a_version; httpPatch("/schemas/" + a_id, body, log_context); } @@ -254,7 +289,6 @@ bool SchemaAPIClient::validateMetadata(const std::string &a_schema_id, std::string &a_warnings, LogContext log_context) { nlohmann::json body; - body["id"] = a_schema_id; body["metadata_format"] = a_metadata_format; body["engine"] = a_engine; body["content"] = a_metadata_content; diff --git a/core/server/SchemaAPIClient.hpp b/core/server/SchemaAPIClient.hpp index a2c4a47dd..98b32ddac 100644 --- a/core/server/SchemaAPIClient.hpp +++ b/core/server/SchemaAPIClient.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -35,38 +36,87 @@ class SchemaAPIClient { bool isConfigured() const { return !m_config.base_url.empty(); } + // ── Custom Headers ──────────────────────────────────────────────────── + + /** + * @brief Set additional HTTP headers appended to every request. + * + * Replaces any previously set custom headers. Headers persist across + * requests until replaced or cleared. + * + * Intended for integration testing (e.g. Prism's Prefer header to select + * response examples/status codes). Production code should not need this. + * + * @param a_headers Map of header name → value. + */ + void setCustomHeaders(const std::map &a_headers); + + /** + * @brief Remove all custom headers. + */ + void clearCustomHeaders(); + // ── Storage Operations ──────────────────────────────────────────────── /** - * @brief Create or replace a schema at the given ID. + * @brief Create or replace a schema at the given ID (PUT /schemas/{id}). + * + * Per OpenAPI spec (SchemaReplace), required body fields: + * name, schema_format, engine, content + * Optional body fields: description, version, revise + * + * @param a_id Schema identifier (path parameter only). + * @param a_name Schema name (required). + * @param a_description Schema description (omitted from body if empty). + * @param a_schema_format Serialization format: "json", "yaml", "xml". + * @param a_engine Validation engine: "JSONSchema", "LinkML", etc. + * @param a_content Raw schema content as text. + * @param a_version Semantic version string (omitted from body if empty). + * @param log_context Logging context. */ void putSchema(const std::string &a_id, const std::string &a_name, const std::string &a_description, - const std::string &a_content, LogContext log_context); + const std::string &a_schema_format, + const std::string &a_engine, const std::string &a_content, + const std::string &a_version, LogContext log_context); /** - * @brief Partially update a schema. + * @brief Partially update a schema (PATCH /schemas/{id}). + * + * Per OpenAPI spec (SchemaPatch), all body fields are optional. + * + * @param a_id Schema identifier (path parameter only). + * @param a_name Updated name (nullopt = unchanged). + * @param a_description Updated description (nullopt = unchanged). + * @param a_schema_format Updated format (nullopt = unchanged). + * @param a_engine Updated engine (nullopt = unchanged). + * @param a_content Updated content (nullopt = unchanged). + * @param a_version Updated version (nullopt = unchanged). + * @param log_context Logging context. */ void patchSchema(const std::string &a_id, const std::optional &a_name, const std::optional &a_description, + const std::optional &a_schema_format, + const std::optional &a_engine, const std::optional &a_content, + const std::optional &a_version, LogContext log_context); /** - * @brief Retrieve a schema by ID. + * @brief Retrieve a schema by ID (GET /schemas/{id}). */ nlohmann::json getSchema(const std::string &a_id, LogContext log_context); /** - * @brief Delete a schema by ID. + * @brief Delete a schema by ID (DELETE /schemas/{id}). */ void deleteSchema(const std::string &a_id, LogContext log_context); // ── Validation Operations ───────────────────────────────────────────── /** - * @brief Validate a schema definition. + * @brief Validate a schema definition (POST /schemas/validate). * * @param a_schema_format Serialization format ("json", "yaml", "xml"). * @param a_engine Validation engine ("JSONSchema", "LinkML", etc.). @@ -81,9 +131,10 @@ class SchemaAPIClient { LogContext log_context); /** - * @brief Validate metadata against a stored schema. + * @brief Validate metadata against a stored schema + * (POST /schemas/{id}/validate). * - * @param a_schema_id Schema ID to validate against. + * @param a_schema_id Schema ID (path parameter only). * @param a_metadata_format Format of the metadata ("json", "yaml"). * @param a_engine Validation engine to use. * @param a_metadata_content Metadata to validate. @@ -119,6 +170,7 @@ class SchemaAPIClient { SchemaAPIConfig m_config; CURL *m_curl; + std::map m_custom_headers; }; } // namespace Core diff --git a/core/server/client_handlers/SchemaHandler.cpp b/core/server/client_handlers/SchemaHandler.cpp index 231488c7b..5afb21b90 100644 --- a/core/server/client_handlers/SchemaHandler.cpp +++ b/core/server/client_handlers/SchemaHandler.cpp @@ -66,8 +66,16 @@ void SchemaHandler::handleCreate(const std::string &a_uid, // Store content through factory (no-op for Arango, meaningful for external) try { + m_schema_factory.getStorage(a_request.type()).storeContent( - a_reply.schema(0).id(), a_request.def(), a_request.desc(), log_context); + a_reply.schema(0).id(), + a_request.def(), + a_request.desc(), + a_request.format(), + a_request.type(), + std::to_string(a_reply.schema(0).ver()), + log_context); + } catch (exception &e) { DL_ERROR(log_context, "Schema storage failed attempting rollback: " << e.what()); try { @@ -137,8 +145,16 @@ void SchemaHandler::handleRevise(const std::string &a_uid, // Store content for new revision only when def was provided if (a_request.has_def()) { try { - m_schema_factory.getStorage(schema_type).storeContent( - a_reply.schema(0).id(), a_request.def(), a_request.desc(), log_context); + + m_schema_factory.getStorage(a_reply.schema(0).type()).storeContent( + a_reply.schema(0).id(), + a_request.def(), + a_request.desc(), + schema_format, + schema_type, + std::to_string(a_reply.schema(0).ver()), + log_context); + } catch (exception &e) { // TODO: Arango revision exists but external storage failed — needs rollback DL_ERROR(log_context, @@ -219,7 +235,14 @@ void SchemaHandler::handleUpdate(const std::string &a_uid, } m_schema_factory.getStorage(schema_type).updateContent( - a_request.id(), a_request.def(), desc, log_context); + a_request.id(), + a_request.def(), + a_request.has_desc() ? std::optional(a_request.desc()) : std::nullopt, + std::nullopt, // schema_format — unchanged on update + std::nullopt, // engine — unchanged on update + std::nullopt, // version — unchanged on update + log_context); + } catch (exception &e) { SchemaUpdateRequest rollback_request; diff --git a/core/server/schema_storage/ArangoSchemaStorage.hpp b/core/server/schema_storage/ArangoSchemaStorage.hpp index 4add4b654..03e10a5f4 100644 --- a/core/server/schema_storage/ArangoSchemaStorage.hpp +++ b/core/server/schema_storage/ArangoSchemaStorage.hpp @@ -1,35 +1,40 @@ #ifndef ARANGOSCHEMASTORAGE_HPP #define ARANGOSCHEMASTORAGE_HPP #pragma once - #include "ISchemaStorage.hpp" - namespace SDMS { namespace Core { - /** * @brief Native Arango-only schema storage. * * Passthrough implementation: content lives in Arango's `def` field. * All methods are trivial because DatabaseAPI's Arango CRUD handles everything. * + * The schema_format, engine, and version parameters are ignored — Arango + * stores these as separate document fields managed by DatabaseAPI, not + * by the storage layer. + * * Thread-safe (stateless). */ class ArangoSchemaStorage : public ISchemaStorage { public: ArangoSchemaStorage() = default; ~ArangoSchemaStorage() override = default; - std::string storeContent(const std::string &a_id, const std::string &a_content, const std::string &a_desc, + const std::string &a_schema_format, + const std::string &a_engine, + const std::string &a_version, LogContext log_context) override { (void)a_id; (void)a_desc; + (void)a_schema_format; + (void)a_engine; + (void)a_version; (void)log_context; return a_content; // Content goes directly into Arango's def field } - StorageRetrieveResult retrieveContent(const std::string &a_id, const std::string &a_arango_def, LogContext log_context) override { @@ -37,17 +42,21 @@ class ArangoSchemaStorage : public ISchemaStorage { (void)log_context; return StorageRetrieveResult::Ok(a_arango_def); // Content IS in Arango } - std::string updateContent(const std::string &a_id, const std::string &a_content, const std::optional &a_desc, + const std::optional &a_schema_format, + const std::optional &a_engine, + const std::optional &a_version, LogContext log_context) override { (void)a_id; (void)a_desc; + (void)a_schema_format; + (void)a_engine; + (void)a_version; (void)log_context; return a_content; } - void deleteContent(const std::string &a_id, LogContext log_context) override { (void)a_id; @@ -55,8 +64,6 @@ class ArangoSchemaStorage : public ISchemaStorage { // No-op — Arango document deletion handles it } }; - } // namespace Core } // namespace SDMS - #endif diff --git a/core/server/schema_storage/ExternalSchemaStorage.cpp b/core/server/schema_storage/ExternalSchemaStorage.cpp index a16c36a9a..93e24d853 100644 --- a/core/server/schema_storage/ExternalSchemaStorage.cpp +++ b/core/server/schema_storage/ExternalSchemaStorage.cpp @@ -1,10 +1,8 @@ #include "ExternalSchemaStorage.hpp" #include "common/TraceException.hpp" #include "common/envelope.pb.h" - namespace SDMS { namespace Core { - ExternalSchemaStorage::ExternalSchemaStorage( std::unique_ptr a_client) : m_client(std::move(a_client)) { @@ -14,37 +12,33 @@ ExternalSchemaStorage::ExternalSchemaStorage( EXCEPT(INTERNAL_ERROR, "ExternalSchemaStorage: client not configured (empty base URL)"); } - -std::string ExternalSchemaStorage::storeContent(const std::string &a_id, - const std::string &a_content, - const std::string &a_desc, - LogContext log_context) { - // Use PUT to enforce the same ID in both Arango and the API +std::string ExternalSchemaStorage::storeContent( + const std::string &a_id, const std::string &a_content, + const std::string &a_desc, const std::string &a_schema_format, + const std::string &a_engine, const std::string &a_version, + LogContext log_context) { + // PUT /schemas/{id} — creates or replaces at this ID. + // name = id is a DataFed convention; the API requires a name field. m_client->putSchema(a_id, a_id, // name = id (convention) - a_desc, a_content, log_context); - + a_desc, a_schema_format, a_engine, a_content, a_version, + log_context); return ""; // Arango def is empty — content lives in the API } - StorageRetrieveResult ExternalSchemaStorage::retrieveContent(const std::string &a_id, const std::string &a_arango_def, LogContext log_context) { (void)a_arango_def; // Ignored — we fetch from the API - try { auto result = m_client->getSchema(a_id, log_context); - if (result.contains("content")) return StorageRetrieveResult::Ok(result["content"].get()); - // API returned but no content field — treat as empty content, not error DL_WARNING(log_context, "ExternalSchemaStorage: API returned no content field for " << a_id); return StorageRetrieveResult::Ok(""); - } catch (TraceException &e) { // Storage unreachable — return explicit failure, don't hide it DL_ERROR(log_context, @@ -54,18 +48,20 @@ ExternalSchemaStorage::retrieveContent(const std::string &a_id, "Schema storage service unavailable: " + e.toString()); } } - std::string ExternalSchemaStorage::updateContent( const std::string &a_id, const std::string &a_content, - const std::optional &a_desc, LogContext log_context) { - + const std::optional &a_desc, + const std::optional &a_schema_format, + const std::optional &a_engine, + const std::optional &a_version, LogContext log_context) { + // PATCH /schemas/{id} — partial update, only sends non-nullopt fields. m_client->patchSchema(a_id, std::nullopt, // name unchanged - a_desc, a_content, log_context); - + a_desc, a_schema_format, a_engine, + std::optional(a_content), a_version, + log_context); return ""; // Arango def stays empty } - void ExternalSchemaStorage::deleteContent(const std::string &a_id, LogContext log_context) { try { @@ -77,6 +73,5 @@ void ExternalSchemaStorage::deleteContent(const std::string &a_id, << a_id << " (orphaned content may remain): " << e.toString()); } } - } // namespace Core } // namespace SDMS diff --git a/core/server/schema_storage/ExternalSchemaStorage.hpp b/core/server/schema_storage/ExternalSchemaStorage.hpp index 3eeb26c11..8acac3402 100644 --- a/core/server/schema_storage/ExternalSchemaStorage.hpp +++ b/core/server/schema_storage/ExternalSchemaStorage.hpp @@ -1,15 +1,11 @@ #ifndef EXTERNALSCHEMASTORAGE_HPP #define EXTERNALSCHEMASTORAGE_HPP #pragma once - #include "ISchemaStorage.hpp" #include "SchemaAPIClient.hpp" - #include - namespace SDMS { namespace Core { - /** * @brief Schema storage backed by the external Schema Management API. * @@ -27,33 +23,31 @@ class ExternalSchemaStorage : public ISchemaStorage { */ explicit ExternalSchemaStorage(std::unique_ptr a_client); ~ExternalSchemaStorage() override = default; - // Non-copyable (owns unique_ptr) ExternalSchemaStorage(const ExternalSchemaStorage &) = delete; ExternalSchemaStorage &operator=(const ExternalSchemaStorage &) = delete; - std::string storeContent(const std::string &a_id, const std::string &a_content, const std::string &a_desc, + const std::string &a_schema_format, + const std::string &a_engine, + const std::string &a_version, LogContext log_context) override; - StorageRetrieveResult retrieveContent(const std::string &a_id, const std::string &a_arango_def, LogContext log_context) override; - std::string updateContent(const std::string &a_id, const std::string &a_content, const std::optional &a_desc, + const std::optional &a_schema_format, + const std::optional &a_engine, + const std::optional &a_version, LogContext log_context) override; - void deleteContent(const std::string &a_id, LogContext log_context) override; - private: std::unique_ptr m_client; }; - } // namespace Core } // namespace SDMS - #endif diff --git a/core/server/tests/CMakeLists.txt b/core/server/tests/CMakeLists.txt index 96a5ce6f4..bc0aac557 100644 --- a/core/server/tests/CMakeLists.txt +++ b/core/server/tests/CMakeLists.txt @@ -1,3 +1,6 @@ if( ENABLE_UNIT_TESTS OR ENABLE_MEMORY_TESTS ) add_subdirectory(unit) endif( ENABLE_UNIT_TESTS OR ENABLE_MEMORY_TESTS ) +if( ENABLE_INTEGRATION_TESTS) + add_subdirectory(integration) +endif( ENABLE_INTEGRATION_TESTS) diff --git a/core/server/tests/integration/CMakeLists.txt b/core/server/tests/integration/CMakeLists.txt index 8477c03f9..e26556b19 100644 --- a/core/server/tests/integration/CMakeLists.txt +++ b/core/server/tests/integration/CMakeLists.txt @@ -1,19 +1,106 @@ -# Each test listed in Alphabetical order +# ============================================================================ +# Integration Tests +# ============================================================================ + +# ── Options ────────────────────────────────────────────────────────────────── + +set(MOCK_SCHEMA_IMAGE "savannah.ornl.gov/datafed/mock-schema:latest" + CACHE STRING "Docker image for the mock Schema API (Prism)") +set(MOCK_SCHEMA_PORT "4010" + CACHE STRING "Host port for the mock Schema API container") +set(MOCK_SCHEMA_PULL "true" + CACHE STRING "Pull the mock image before starting (set to 'false' for offline/local builds)") + +# ── 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") +endif() + +# ── Compile and register all integration tests ────────────────────────────── + foreach(PROG test_SchemaServiceFactory + test_SchemaAPIClient ) - file(GLOB ${PROG}_SOURCES ${PROG}*.cpp) add_executable(integration_${PROG} ${${PROG}_SOURCES}) target_link_libraries(integration_${PROG} PUBLIC datafed-core-lib ${DATAFED_BOOST_LIBRARIES}) if(BUILD_SHARED_LIBS) target_compile_definitions(integration_${PROG} PRIVATE BOOST_TEST_DYN_LINK) endif() - if ( ENABLE_UNIT_TESTS ) + if(ENABLE_UNIT_TESTS) add_test(integration_${PROG} integration_${PROG}) - endif( ENABLE_UNIT_TESTS ) - if ( ENABLE_MEMORY_TESTS ) - add_test(NAME memory_${PROG} COMMAND valgrind --leak-check=full --error-exitcode=1 $) - endif( ENABLE_MEMORY_TESTS ) - + endif(ENABLE_UNIT_TESTS) + if(ENABLE_MEMORY_TESTS) + add_test(NAME memory_${PROG} COMMAND valgrind --leak-check=full --error-exitcode=1 $) + endif(ENABLE_MEMORY_TESTS) endforeach(PROG) + +# ── Docker fixture for SchemaAPIClient ─────────────────────────────────────── +# +# CTest fixtures: a setup test starts the container, a cleanup test stops it, +# and the actual test declares FIXTURES_REQUIRED so CTest enforces ordering. +# If Docker is not found, the test binary still compiles but the fixture tests +# are not registered — CTest will skip tests with unsatisfied fixture deps. +# +# Prism config: +# PRISM_ERRORS=true — rejects requests that violate the OpenAPI spec +# PRISM_DYNAMIC=false — returns spec examples, not random data +# ============================================================================ + +if(DOCKER_EXECUTABLE) + + set(_mock_schema_env + "MOCK_SCHEMA_IMAGE=${MOCK_SCHEMA_IMAGE}" + "MOCK_SCHEMA_PORT=${MOCK_SCHEMA_PORT}" + "MOCK_SCHEMA_PULL=${MOCK_SCHEMA_PULL}" + "CONTAINER_NAME=datafed-mock-schema" + ) + + set(_test_env + "SCHEMA_API_TEST_URL=http://localhost:${MOCK_SCHEMA_PORT}" + ) + + # ── Fixture setup: start Prism container ───────────────────────────── + + add_test( + NAME mock_schema_start + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/start_mock_schema.sh + ) + set_tests_properties(mock_schema_start PROPERTIES + FIXTURES_SETUP MockSchemaAPI + ENVIRONMENT "${_mock_schema_env}" + LABELS "fixture;integration" + ) + + # ── Fixture cleanup: stop and remove container ─────────────────────── + + add_test( + NAME mock_schema_stop + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/stop_mock_schema.sh + ) + set_tests_properties(mock_schema_stop PROPERTIES + FIXTURES_CLEANUP MockSchemaAPI + ENVIRONMENT "${_mock_schema_env}" + LABELS "fixture;integration" + ) + + # ── Attach fixture to SchemaAPIClient tests ────────────────────────── + + set_tests_properties(integration_test_SchemaAPIClient PROPERTIES + FIXTURES_REQUIRED MockSchemaAPI + ENVIRONMENT "${_test_env}" + LABELS "integration" + ) + + if(ENABLE_MEMORY_TESTS) + set_tests_properties(memory_test_SchemaAPIClient PROPERTIES + FIXTURES_REQUIRED MockSchemaAPI + ENVIRONMENT "${_test_env}" + LABELS "integration;memory" + ) + endif() + +endif() diff --git a/core/server/tests/integration/start_mock_schema.sh b/core/server/tests/integration/start_mock_schema.sh new file mode 100755 index 000000000..c7973df56 --- /dev/null +++ b/core/server/tests/integration/start_mock_schema.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# start_mock_schema.sh — Start the Prism mock Schema API container. +# +# Called by CTest as a fixture setup step. Environment variables are +# injected by CMake via set_tests_properties(... ENVIRONMENT ...). +# +# Optional env: +# MOCK_SCHEMA_IMAGE — Docker image (default: savannah.ornl.gov/datafed/mock-schema:latest) +# MOCK_SCHEMA_PORT — Host port to bind (default: 4010) +# CONTAINER_NAME — Docker container name (default: datafed-mock-schema) +# MOCK_SCHEMA_PULL — "true" to pull image before starting (default: true) + +set -eu + +IMAGE="${MOCK_SCHEMA_IMAGE:-savannah.ornl.gov/datafed/mock-schema:latest}" +PORT="${MOCK_SCHEMA_PORT:-4010}" +NAME="${CONTAINER_NAME:-datafed-mock-schema}" +PULL="${MOCK_SCHEMA_PULL:-true}" +MAX_WAIT=30 + +# ── Validate ───────────────────────────────────────────────────────────────── + +if ! command -v docker &>/dev/null; then + echo "ERROR: docker not found in PATH" >&2 + exit 1 +fi + +# ── Cleanup any stale container ────────────────────────────────────────────── + +docker rm -f "${NAME}" &>/dev/null || true + +# ── Pull (optional, skipped for local builds) ──────────────────────────────── + +if [ "${PULL}" = "true" ]; then + echo "Pulling ${IMAGE}..." + docker pull "${IMAGE}" +fi + +# ── Start container ────────────────────────────────────────────────────────── + +echo "Starting mock schema server on port ${PORT}..." + +docker run -d \ + --name "${NAME}" \ + -p "${PORT}:4010" \ + -e PRISM_DYNAMIC=false \ + -e PRISM_ERRORS=true \ + "${IMAGE}" + +# ── Wait for readiness ─────────────────────────────────────────────────────── +# Hit the list endpoint — any 2xx means Prism is serving the spec. + +echo "Waiting for readiness (max ${MAX_WAIT}s)..." + +for i in $(seq 1 ${MAX_WAIT}); do + if curl -sf -o /dev/null "http://localhost:${PORT}/schemas?page=1&limit=1" 2>/dev/null; then + echo "Mock schema server ready on port ${PORT} (took ${i}s)" + exit 0 + fi + sleep 1 +done + +echo "ERROR: mock schema server did not become ready in ${MAX_WAIT}s" >&2 +echo "--- container logs ---" +docker logs "${NAME}" 2>&1 || true +echo "--- end logs ---" +docker rm -f "${NAME}" &>/dev/null || true +exit 1 diff --git a/core/server/tests/integration/stop_mock_schema.sh b/core/server/tests/integration/stop_mock_schema.sh new file mode 100755 index 000000000..aa318e128 --- /dev/null +++ b/core/server/tests/integration/stop_mock_schema.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# stop_mock_schema.sh — Stop and remove the Prism mock Schema API container. +# +# Called by CTest as a fixture cleanup step. Always exits 0 — a missing +# container is not an error (the test may have already cleaned up, or +# startup may have failed). +# +# Optional env: +# CONTAINER_NAME — Docker container name (default: datafed-mock-schema) + +set -u + +NAME="${CONTAINER_NAME:-datafed-mock-schema}" + +if docker rm -f "${NAME}" &>/dev/null; then + echo "Stopped and removed container: ${NAME}" +else + echo "Container ${NAME} was not running (already removed or never started)" +fi + +exit 0 diff --git a/core/server/tests/integration/test_SchemaAPIClient.cpp b/core/server/tests/integration/test_SchemaAPIClient.cpp new file mode 100644 index 000000000..6dce1b716 --- /dev/null +++ b/core/server/tests/integration/test_SchemaAPIClient.cpp @@ -0,0 +1,820 @@ +#define BOOST_TEST_MAIN +#define BOOST_TEST_MODULE SchemaAPIClientIntegration +#include + +// Local private includes +#include "SchemaAPIClient.hpp" +#include "SchemaAPIConfig.hpp" + +// DataFed Common includes +#include "common/TraceException.hpp" + +#include + +#include +#include +#include + +using namespace SDMS::Core; +using SDMS::LogContext; +using json = nlohmann::json; + +// ============================================================================ +// Fixture +// +// Reads SCHEMA_API_TEST_URL from environment (default: http://localhost:4010). +// +// Helper methods set/clear the Prefer header between requests to steer Prism +// to specific response codes and named examples. +// +// Recommended Prism config: +// PRISM_ERRORS=true — rejects requests violating the OpenAPI spec +// PRISM_DYNAMIC=false — returns spec examples (not random data) +// ============================================================================ + +struct PrismFixture { + std::unique_ptr client; + + PrismFixture() { client = makeClient(); } + + std::unique_ptr makeClient() { + SchemaAPIConfig cfg; + const char *url = std::getenv("SCHEMA_API_TEST_URL"); + cfg.base_url = url ? url : "http://localhost:4010"; + cfg.verify_ssl = false; + cfg.connect_timeout_sec = 5; + cfg.request_timeout_sec = 10; + return std::make_unique(cfg); + } + + static LogContext ctx() { + LogContext c; + c.thread_name = "integration-test"; + c.thread_id = 0; + c.correlation_id = "integration-test-corr"; + return c; + } + + /// Set Prism to return a specific HTTP status code. + void preferCode(int code) { + client->setCustomHeaders( + {{"Prefer", "code=" + std::to_string(code)}}); + } + + /// Set Prism to return a specific named example for the response. + void preferExample(const std::string &name) { + client->setCustomHeaders( + {{"Prefer", "example=" + name}}); + } + + /// Set Prism to return a specific code AND named example. + void preferCodeAndExample(int code, const std::string &name) { + client->setCustomHeaders( + {{"Prefer", "code=" + std::to_string(code) + + ", example=" + name}}); + } + + /// Clear Prefer header — Prism returns default (first) example. + void preferDefault() { client->clearCustomHeaders(); } +}; + +// ============================================================================ +// Constants +// ============================================================================ + +namespace { + +const std::string TEST_SCHEMA_ID = "test-schema:1"; + +const std::string VALID_JSON_SCHEMA = R"({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name"] +})"; + +const std::string VALID_METADATA = R"({"name": "Alice", "age": 30})"; + +} // anonymous namespace + +// ============================================================================ +// PUT /schemas/{id} — Happy Path +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(PutSchemaHappy, PrismFixture) + +BOOST_AUTO_TEST_CASE(put_accepted_with_all_required_fields) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "Person Schema", "A person object", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log)); +} + +BOOST_AUTO_TEST_CASE(put_accepted_without_optional_fields) { + auto log = ctx(); + + // description and version are optional per SchemaReplace + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "Minimal", "", "json", "JSONSchema", + VALID_JSON_SCHEMA, "", log)); +} + +BOOST_AUTO_TEST_CASE(put_yaml_linkml_accepted) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "YAML Schema", "LinkML test", "yaml", "LinkML", + "id: https://example.org/person", "1.0.0", log)); +} + +BOOST_AUTO_TEST_CASE(put_replaced_example) { + preferExample("replaced"); + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "Test", "", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log)); +} + +BOOST_AUTO_TEST_CASE(put_revised_example) { + preferExample("revised"); + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "Test", "", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// PUT /schemas/{id} — Error Paths +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(PutSchemaErrors, PrismFixture) + +BOOST_AUTO_TEST_CASE(put_400_throws_service_error) { + preferCode(400); + auto log = ctx(); + + BOOST_CHECK_THROW( + client->putSchema(TEST_SCHEMA_ID, "X", "", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(put_500_throws_service_error) { + preferCode(500); + auto log = ctx(); + + BOOST_CHECK_THROW( + client->putSchema(TEST_SCHEMA_ID, "X", "", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// GET /schemas/{id} — Happy Path +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(GetSchemaHappy, PrismFixture) + +BOOST_AUTO_TEST_CASE(get_returns_valid_schema_object) { + auto log = ctx(); + + json result; + BOOST_REQUIRE_NO_THROW(result = client->getSchema(TEST_SCHEMA_ID, log)); + + // SchemaGetResult required fields per spec + BOOST_TEST(result.contains("id")); + BOOST_TEST(result.contains("name")); + BOOST_TEST(result.contains("schema_format")); + BOOST_TEST(result.contains("engine")); + BOOST_TEST(result.contains("content")); + BOOST_TEST(result.contains("created_at")); + BOOST_TEST(result.contains("updated_at")); +} + +BOOST_AUTO_TEST_CASE(get_json_schema_example) { + preferExample("json-schema"); + auto log = ctx(); + + json result = client->getSchema(TEST_SCHEMA_ID, log); + + BOOST_TEST(result["schema_format"].get() == "json"); + BOOST_TEST(result["engine"].get() == "JSONSchema"); +} + +BOOST_AUTO_TEST_CASE(get_yaml_schema_example) { + preferExample("yaml-schema"); + auto log = ctx(); + + json result = client->getSchema(TEST_SCHEMA_ID, log); + + BOOST_TEST(result["schema_format"].get() == "yaml"); + BOOST_TEST(result["engine"].get() == "LinkML"); +} + +BOOST_AUTO_TEST_CASE(get_returns_string_typed_fields) { + auto log = ctx(); + json result = client->getSchema(TEST_SCHEMA_ID, log); + + BOOST_TEST(result["id"].is_string()); + BOOST_TEST(result["name"].is_string()); + BOOST_TEST(result["content"].is_string()); + BOOST_TEST(result["schema_format"].is_string()); + BOOST_TEST(result["engine"].is_string()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// GET /schemas/{id} — Error Paths +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(GetSchemaErrors, PrismFixture) + +BOOST_AUTO_TEST_CASE(get_404_throws) { + preferCode(404); + auto log = ctx(); + + BOOST_CHECK_THROW(client->getSchema(TEST_SCHEMA_ID, log), TraceException); +} + +BOOST_AUTO_TEST_CASE(get_500_throws) { + preferCode(500); + auto log = ctx(); + + BOOST_CHECK_THROW(client->getSchema(TEST_SCHEMA_ID, log), TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// PATCH /schemas/{id} — Happy Path +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(PatchSchemaHappy, PrismFixture) + +BOOST_AUTO_TEST_CASE(patch_name_only) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("Renamed"), + std::nullopt, // description + std::nullopt, // schema_format + std::nullopt, // engine + std::nullopt, // content + std::nullopt, // version + log)); +} + +BOOST_AUTO_TEST_CASE(patch_content_and_version) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::optional(VALID_JSON_SCHEMA), + std::optional("2.0.0"), + log)); +} + +BOOST_AUTO_TEST_CASE(patch_all_fields) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("Full Update"), + std::optional("Everything changed"), + std::optional("json"), + std::optional("JSONSchema"), + std::optional(VALID_JSON_SCHEMA), + std::optional("3.0.0"), + log)); +} + +BOOST_AUTO_TEST_CASE(patch_patched_example) { + preferExample("patched"); + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("Patched"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log)); +} + +BOOST_AUTO_TEST_CASE(patch_revised_example) { + preferExample("revised"); + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("Revised"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// PATCH /schemas/{id} — Error Paths +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(PatchSchemaErrors, PrismFixture) + +BOOST_AUTO_TEST_CASE(patch_404_throws) { + preferCode(404); + auto log = ctx(); + + BOOST_CHECK_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("X"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(patch_400_throws) { + preferCode(400); + auto log = ctx(); + + BOOST_CHECK_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("X"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(patch_500_throws) { + preferCode(500); + auto log = ctx(); + + BOOST_CHECK_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("X"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// DELETE /schemas/{id} +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(DeleteSchema, PrismFixture) + +BOOST_AUTO_TEST_CASE(delete_204_succeeds) { + // Default Prism response for DELETE is 204 + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->deleteSchema(TEST_SCHEMA_ID, log)); +} + +BOOST_AUTO_TEST_CASE(delete_404_succeeds) { + // Client explicitly treats 404 as acceptable for DELETE + preferCode(404); + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->deleteSchema(TEST_SCHEMA_ID, log)); +} + +BOOST_AUTO_TEST_CASE(delete_500_throws) { + preferCode(500); + auto log = ctx(); + + BOOST_CHECK_THROW(client->deleteSchema(TEST_SCHEMA_ID, log), TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// POST /schemas/validate — Happy Path +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(ValidateSchemaHappy, PrismFixture) + +BOOST_AUTO_TEST_CASE(validate_schema_returns_true) { + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == true); + BOOST_TEST(errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_schema_no_warnings_example) { + preferExample("no-warnings"); + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == true); +} + +BOOST_AUTO_TEST_CASE(validate_schema_with_warnings_example) { + // The with-warnings example still returns 200, so the client should + // return true. The warnings field isn't captured by validateSchema + // (only validateMetadata captures warnings), but the client must not + // choke on the extra field. + preferExample("with-warnings"); + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == true); +} + +BOOST_AUTO_TEST_CASE(validate_yaml_engine_accepted) { + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema( + "yaml", "LinkML", "id: https://example.org/test", errors, log); + + BOOST_TEST(result == true); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// POST /schemas/validate — Error Paths +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(ValidateSchemaErrors, PrismFixture) + +BOOST_AUTO_TEST_CASE(validate_schema_422_returns_false) { + preferCode(422); + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_schema_422_invalid_type_example) { + preferCodeAndExample(422, "invalid-type"); + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_schema_422_malformed_example) { + preferCodeAndExample(422, "malformed"); + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_schema_400_throws) { + preferCode(400); + auto log = ctx(); + std::string errors; + + // 400 is not handled by validateSchema — falls through to the final + // EXCEPT_PARAM which throws SERVICE_ERROR + BOOST_CHECK_THROW( + client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(validate_schema_500_throws) { + preferCode(500); + auto log = ctx(); + std::string errors; + + BOOST_CHECK_THROW( + client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// POST /schemas/{id}/validate — Happy Path +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(ValidateMetadataHappy, PrismFixture) + +BOOST_AUTO_TEST_CASE(validate_metadata_returns_true) { + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == true); + BOOST_TEST(errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_no_warnings_example) { + preferExample("no-warnings"); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == true); + BOOST_TEST(warnings.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_with_warnings_example) { + preferExample("with-warnings"); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == true); + BOOST_TEST(!warnings.empty()); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// POST /schemas/{id}/validate — Error Paths +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(ValidateMetadataErrors, PrismFixture) + +BOOST_AUTO_TEST_CASE(validate_metadata_422_returns_false) { + preferCode(422); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_422_missing_required) { + preferCodeAndExample(422, "missing-required"); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_422_type_mismatch) { + preferCodeAndExample(422, "type-mismatch"); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_422_multiple_errors) { + preferCodeAndExample(422, "multiple-errors"); + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + + BOOST_TEST(result == false); + BOOST_TEST(!errors.empty()); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_404_throws) { + preferCode(404); + auto log = ctx(); + std::string errors, warnings; + + BOOST_CHECK_THROW( + client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_400_throws) { + preferCode(400); + auto log = ctx(); + std::string errors, warnings; + + BOOST_CHECK_THROW( + client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_500_throws) { + preferCode(500); + auto log = ctx(); + std::string errors, warnings; + + BOOST_CHECK_THROW( + client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log), + TraceException); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Client Configuration +// ============================================================================ + +BOOST_AUTO_TEST_SUITE(ClientConfiguration) + +BOOST_AUTO_TEST_CASE(empty_base_url_not_configured) { + SchemaAPIConfig cfg; + cfg.base_url = ""; + SchemaAPIClient unconfigured(cfg); + + BOOST_TEST(unconfigured.isConfigured() == false); +} + +BOOST_AUTO_TEST_CASE(unconfigured_client_throws_on_all_operations) { + SchemaAPIConfig cfg; + cfg.base_url = ""; + SchemaAPIClient unconfigured(cfg); + + LogContext log; + log.thread_name = "test"; + log.thread_id = 0; + log.correlation_id = "test"; + + BOOST_CHECK_THROW( + unconfigured.putSchema("x", "x", "", "json", "JSONSchema", "{}", "1.0.0", + log), + TraceException); + + BOOST_CHECK_THROW(unconfigured.getSchema("x", log), TraceException); + + BOOST_CHECK_THROW(unconfigured.deleteSchema("x", log), TraceException); + + BOOST_CHECK_THROW( + unconfigured.patchSchema("x", std::optional("y"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log), + TraceException); + + std::string e, w; + BOOST_CHECK_THROW( + unconfigured.validateSchema("json", "JSONSchema", "{}", e, log), + TraceException); + + BOOST_CHECK_THROW( + unconfigured.validateMetadata("x", "json", "JSONSchema", "{}", e, w, + log), + TraceException); +} + +BOOST_AUTO_TEST_CASE(trailing_slash_normalized) { + SchemaAPIConfig cfg; + cfg.base_url = "http://localhost:4010/"; + cfg.verify_ssl = false; + + SchemaAPIClient client(cfg); + BOOST_TEST(client.isConfigured() == true); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Custom Headers (verify the mechanism works end-to-end) +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(CustomHeaders, PrismFixture) + +BOOST_AUTO_TEST_CASE(set_and_clear_custom_headers) { + auto log = ctx(); + + // Force 404 via Prefer header + preferCode(404); + BOOST_CHECK_THROW(client->getSchema(TEST_SCHEMA_ID, log), TraceException); + + // Clear headers — should get default 200 + preferDefault(); + json result; + BOOST_CHECK_NO_THROW(result = client->getSchema(TEST_SCHEMA_ID, log)); + BOOST_TEST(result.contains("id")); +} + +BOOST_AUTO_TEST_CASE(custom_headers_persist_across_calls) { + auto log = ctx(); + + preferCode(500); + + // Both calls should get 500 + BOOST_CHECK_THROW(client->getSchema("a:1", log), TraceException); + BOOST_CHECK_THROW(client->getSchema("b:2", log), TraceException); + + preferDefault(); +} + +BOOST_AUTO_TEST_CASE(custom_headers_replaced_not_accumulated) { + auto log = ctx(); + + // Set to 500 + preferCode(500); + BOOST_CHECK_THROW(client->getSchema(TEST_SCHEMA_ID, log), TraceException); + + // Replace with 404 — should NOT still have 500 + preferCode(404); + BOOST_CHECK_THROW(client->getSchema(TEST_SCHEMA_ID, log), TraceException); + + // The 404 path in httpGet throws BAD_REQUEST, while 500 throws + // SERVICE_ERROR. Both are TraceException, but at least we confirm the + // header was replaced (if it were still 500 and somehow the 404 handler + // ran, something would be very wrong). + + preferDefault(); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Contract Conformance +// +// These tests exist solely to verify the request body structure conforms +// to the OpenAPI spec. With PRISM_ERRORS=true, a non-conformant body causes +// Prism to return a validation error, which the client propagates as a +// throw. If these pass, the request bodies match the spec. +// ============================================================================ + +BOOST_FIXTURE_TEST_SUITE(RequestConformance, PrismFixture) + +BOOST_AUTO_TEST_CASE(put_body_conforms_to_SchemaReplace) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW(client->putSchema( + TEST_SCHEMA_ID, "Test", "Desc", "json", "JSONSchema", + VALID_JSON_SCHEMA, "1.0.0", log)); +} + +BOOST_AUTO_TEST_CASE(patch_body_conforms_to_SchemaPatch) { + auto log = ctx(); + + BOOST_CHECK_NO_THROW( + client->patchSchema(TEST_SCHEMA_ID, + std::optional("Patched"), + std::nullopt, std::nullopt, std::nullopt, + std::nullopt, std::nullopt, log)); +} + +BOOST_AUTO_TEST_CASE(validate_schema_body_conforms_to_SchemaValidate) { + auto log = ctx(); + std::string errors; + + bool result = client->validateSchema("json", "JSONSchema", VALID_JSON_SCHEMA, + errors, log); + BOOST_TEST(result == true); +} + +BOOST_AUTO_TEST_CASE(validate_metadata_body_conforms_to_SchemaMetadataValidate) { + auto log = ctx(); + std::string errors, warnings; + + bool result = client->validateMetadata(TEST_SCHEMA_ID, "json", "JSONSchema", + VALID_METADATA, errors, warnings, log); + BOOST_TEST(result == true); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/core/server/tests/integration/test_SchemaServiceFactory.cpp b/core/server/tests/integration/test_SchemaServiceFactory.cpp index bf10be77f..b57d27e2b 100644 --- a/core/server/tests/integration/test_SchemaServiceFactory.cpp +++ b/core/server/tests/integration/test_SchemaServiceFactory.cpp @@ -4,8 +4,9 @@ // Local includes #include "SchemaServiceFactory.hpp" -#include "JsonSchemaValidator.hpp" -#include "NullSchemaValidator.hpp" +#include "ISchemaStorage.hpp" +#include "schema_validators/JsonSchemaValidator.hpp" +#include "schema_validators/NullSchemaValidator.hpp" // Standard includes #include @@ -51,6 +52,58 @@ const std::string INVALID_METADATA_WRONG_TYPE = R"({ "age": "not a number" })"; +// --------------------------------------------------------------------------- +// Minimal mock — only needs to satisfy setDefaultSchemaType's precondition +// that a storage is registered. No storage methods are exercised by these +// tests, so every override is a no-op / trivial return. +// --------------------------------------------------------------------------- +class MockStorage : public ISchemaStorage { +public: + explicit MockStorage(const std::string &a_label) : m_label(a_label) {} + + std::string storeContent(const std::string & /*a_id*/, + const std::string &a_content, + const std::string & /*a_desc*/, + const std::string & /*a_schema_format*/, + const std::string & /*a_engine*/, + const std::string & /*a_version*/, + LogContext /*log_context*/) override { + return a_content; + } + + StorageRetrieveResult retrieveContent(const std::string & /*a_id*/, + const std::string &a_arango_def, + LogContext /*log_context*/) override { + return StorageRetrieveResult::Ok(a_arango_def); + } + + std::string updateContent(const std::string & /*a_id*/, + const std::string &a_content, + const std::optional & /*a_desc*/, + const std::optional & /*a_schema_format*/, + const std::optional & /*a_engine*/, + const std::optional & /*a_version*/, + LogContext /*log_context*/) override { + return a_content; + } + + void deleteContent(const std::string & /*a_id*/, + LogContext /*log_context*/) override {} + +private: + std::string m_label; +}; + +/// Helper: register a NullSchemaValidator as the default fallback engine. +/// The factory requires both storage and validator for an engine before +/// it can be set as default via setDefaultSchemaType. +void registerNullDefault(SchemaServiceFactory &factory, + const std::string &engine = "null-default") { + factory.registerValidator(engine, std::make_shared()); + factory.registerStorage(engine, std::make_shared("default")); + factory.setDefaultSchemaType(engine); +} + } // anonymous namespace // ============================================================================ @@ -63,19 +116,19 @@ BOOST_AUTO_TEST_CASE(register_json_schema_validator) { SchemaServiceFactory factory; auto json_validator = std::make_shared(); - factory.registerValidator("JSONSchema", json_validator); + factory.registerValidator("json-schema", json_validator); - ISchemaValidator &retrieved = factory.getValidator("JSONSchema"); + ISchemaValidator &retrieved = factory.getValidator("json-schema"); BOOST_TEST(retrieved.hasValidationCapability() == true); } BOOST_AUTO_TEST_CASE(validate_definition_through_factory) { SchemaServiceFactory factory; auto json_validator = std::make_shared(); - factory.registerValidator("JSONSchema", json_validator); + factory.registerValidator("json-schema", json_validator); auto ctx = makeTestLogContext(); - ISchemaValidator &validator = factory.getValidator("JSONSchema"); + ISchemaValidator &validator = factory.getValidator("json-schema"); auto result = validator.validateDefinition("json", VALID_SCHEMA, ctx); BOOST_TEST(result.valid == true); @@ -85,10 +138,10 @@ BOOST_AUTO_TEST_CASE(validate_definition_through_factory) { BOOST_AUTO_TEST_CASE(validate_metadata_through_factory) { SchemaServiceFactory factory; auto json_validator = std::make_shared(); - factory.registerValidator("JSONSchema", json_validator); + factory.registerValidator("json-schema", json_validator); auto ctx = makeTestLogContext(); - ISchemaValidator &validator = factory.getValidator("JSONSchema"); + ISchemaValidator &validator = factory.getValidator("json-schema"); // Cache schema first bool cached = validator.cacheSchema("test-schema", VALID_SCHEMA, "json", ctx); @@ -102,10 +155,10 @@ BOOST_AUTO_TEST_CASE(validate_metadata_through_factory) { BOOST_AUTO_TEST_CASE(invalid_metadata_fails_through_factory) { SchemaServiceFactory factory; auto json_validator = std::make_shared(); - factory.registerValidator("JSONSchema", json_validator); + factory.registerValidator("json-schema", json_validator); auto ctx = makeTestLogContext(); - ISchemaValidator &validator = factory.getValidator("JSONSchema"); + ISchemaValidator &validator = factory.getValidator("json-schema"); validator.cacheSchema("test-schema", VALID_SCHEMA, "json", ctx); auto result = validator.validateMetadata( @@ -125,16 +178,19 @@ BOOST_AUTO_TEST_SUITE(FactoryWithNullDefault) BOOST_AUTO_TEST_CASE(null_validator_as_default_for_legacy) { SchemaServiceFactory factory; - auto null_validator = std::make_shared(); auto json_validator = std::make_shared(); - factory.setDefaultValidator(null_validator); - factory.registerValidator("JSONSchema", json_validator); + // Register json-schema engine (needs both validator + storage) + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); + + // Register null-default as the fallback engine + registerNullDefault(factory); - // JSONSchema engine gets real validation - BOOST_TEST(factory.getValidator("JSONSchema").hasValidationCapability() == true); + // json-schema engine gets real validation + BOOST_TEST(factory.getValidator("json-schema").hasValidationCapability() == true); - // Empty/unknown engines get null validator (no validation) + // Empty/unknown engines fall back to the null-default engine BOOST_TEST(factory.getValidator("").hasValidationCapability() == false); BOOST_TEST(factory.getValidator("native").hasValidationCapability() == false); BOOST_TEST(factory.getValidator("other").hasValidationCapability() == false); @@ -142,13 +198,15 @@ BOOST_AUTO_TEST_CASE(null_validator_as_default_for_legacy) { BOOST_AUTO_TEST_CASE(null_validator_accepts_anything) { SchemaServiceFactory factory; - auto null_validator = std::make_shared(); - factory.setDefaultValidator(null_validator); auto ctx = makeTestLogContext(); + // Register null-default as the fallback engine + registerNullDefault(factory); + + // "legacy" is unregistered, resolves to the null-default ISchemaValidator &validator = factory.getValidator("legacy"); - // Invalid JSON passes with null validator + // Invalid JSON passes with null validator — no validation capability auto result = validator.validateDefinition("json", "{ broken json }", ctx); BOOST_TEST(result.valid == true); } @@ -166,17 +224,22 @@ BOOST_AUTO_TEST_CASE(different_engines_use_different_validators) { auto json_validator = std::make_shared(); auto null_validator = std::make_shared(); - factory.registerValidator("JSONSchema", json_validator); + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); + factory.registerValidator("other", null_validator); - factory.setDefaultValidator(null_validator); + factory.registerStorage("other", std::make_shared("other")); + + // Set "other" (null) as the default fallback + factory.setDefaultSchemaType("other"); - // JSONSchema has validation capability - BOOST_TEST(factory.getValidator("JSONSchema").hasValidationCapability() == true); + // json-schema has validation capability + BOOST_TEST(factory.getValidator("json-schema").hasValidationCapability() == true); // "other" explicitly registered as null BOOST_TEST(factory.getValidator("other").hasValidationCapability() == false); - // Unknown falls back to default (null) + // Unknown falls back to default ("other" / null) BOOST_TEST(factory.getValidator("unknown").hasValidationCapability() == false); } @@ -221,15 +284,21 @@ BOOST_AUTO_TEST_CASE(typical_datafed_setup) { // JsonSchemaValidator for JSON Schema validation auto json_validator = std::make_shared(); - // Configure factory - factory.setDefaultValidator(null_validator); // Legacy default - factory.registerValidator("JSONSchema", json_validator); + // Configure factory — register both engines with storage + validator + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); + + factory.registerValidator("legacy-null", null_validator); + factory.registerStorage("legacy-null", std::make_shared("legacy")); + + // Legacy null engine is the fallback for empty/unknown engine strings + factory.setDefaultSchemaType("legacy-null"); auto ctx = makeTestLogContext(); - // Test: JSONSchema engine validates properly + // Test: json-schema engine validates properly { - ISchemaValidator &v = factory.getValidator("JSONSchema"); + ISchemaValidator &v = factory.getValidator("json-schema"); auto def_result = v.validateDefinition("json", VALID_SCHEMA, ctx); BOOST_TEST(def_result.valid == true); diff --git a/core/server/tests/unit/test_SchemaServiceFactory.cpp b/core/server/tests/unit/test_SchemaServiceFactory.cpp index f0b70c55e..b57d27e2b 100644 --- a/core/server/tests/unit/test_SchemaServiceFactory.cpp +++ b/core/server/tests/unit/test_SchemaServiceFactory.cpp @@ -1,13 +1,12 @@ #define BOOST_TEST_MAIN -#define BOOST_TEST_MODULE SchemaServiceFactory +#define BOOST_TEST_MODULE SchemaServiceFactoryIntegration #include // Local includes #include "SchemaServiceFactory.hpp" -#include "ISchemaValidator.hpp" - -// Public includes -#include "common/TraceException.hpp" +#include "ISchemaStorage.hpp" +#include "schema_validators/JsonSchemaValidator.hpp" +#include "schema_validators/NullSchemaValidator.hpp" // Standard includes #include @@ -17,247 +16,317 @@ using namespace SDMS::Core; using SDMS::LogContext; // ============================================================================ -// Mock Validator for Testing +// Test Helpers // ============================================================================ namespace { -/** - * @brief Minimal mock validator that tracks its identity. - */ -class MockValidator : public ISchemaValidator { -public: - explicit MockValidator(const std::string &name) : m_name(name) {} - - ValidationResult validateDefinition(const std::string & /*format*/, - const std::string & /*content*/, - LogContext /*ctx*/) override { - return ValidationResult::Ok(); - } - - ValidationResult validateMetadata(const std::string & /*schema_id*/, - const std::string & /*format*/, - const std::string & /*content*/, - LogContext /*ctx*/) override { - return ValidationResult::Ok(); - } - - bool hasValidationCapability() const override { return true; } - - const std::string &getName() const { return m_name; } - -private: - std::string m_name; -}; - - - -// ============================================================================ -// Add MockStorage alongside MockValidator -// ============================================================================ +LogContext makeTestLogContext() { + LogContext ctx; + ctx.thread_name = "integration-test"; + ctx.thread_id = 0; + ctx.correlation_id = "integration-test-correlation-id"; + return ctx; +} -/** - * @brief Minimal mock storage that tracks its identity. - */ +const std::string VALID_SCHEMA = R"({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name"] +})"; + +const std::string VALID_METADATA = R"({ + "name": "Alice", + "age": 30 +})"; + +const std::string INVALID_METADATA_MISSING_REQUIRED = R"({ + "age": 30 +})"; + +const std::string INVALID_METADATA_WRONG_TYPE = R"({ + "name": "Alice", + "age": "not a number" +})"; + +// --------------------------------------------------------------------------- +// Minimal mock — only needs to satisfy setDefaultSchemaType's precondition +// that a storage is registered. No storage methods are exercised by these +// tests, so every override is a no-op / trivial return. +// --------------------------------------------------------------------------- class MockStorage : public ISchemaStorage { public: - explicit MockStorage(const std::string &name) : m_name(name) {} + explicit MockStorage(const std::string &a_label) : m_label(a_label) {} - std::string storeContent(const std::string & /*id*/, + std::string storeContent(const std::string & /*a_id*/, const std::string &a_content, - const std::string & /*desc*/, - LogContext /*ctx*/) override { + const std::string & /*a_desc*/, + const std::string & /*a_schema_format*/, + const std::string & /*a_engine*/, + const std::string & /*a_version*/, + LogContext /*log_context*/) override { return a_content; } - StorageRetrieveResult retrieveContent(const std::string & /*id*/, + StorageRetrieveResult retrieveContent(const std::string & /*a_id*/, const std::string &a_arango_def, - LogContext /*ctx*/) override { + LogContext /*log_context*/) override { return StorageRetrieveResult::Ok(a_arango_def); } - std::string updateContent(const std::string & /*id*/, + std::string updateContent(const std::string & /*a_id*/, const std::string &a_content, - const std::optional & /*desc*/, - LogContext /*ctx*/) override { + const std::optional & /*a_desc*/, + const std::optional & /*a_schema_format*/, + const std::optional & /*a_engine*/, + const std::optional & /*a_version*/, + LogContext /*log_context*/) override { return a_content; } - void deleteContent(const std::string & /*id*/, - LogContext /*ctx*/) override {} - - const std::string &getName() const { return m_name; } + void deleteContent(const std::string & /*a_id*/, + LogContext /*log_context*/) override {} private: - std::string m_name; + std::string m_label; }; +/// Helper: register a NullSchemaValidator as the default fallback engine. +/// The factory requires both storage and validator for an engine before +/// it can be set as default via setDefaultSchemaType. +void registerNullDefault(SchemaServiceFactory &factory, + const std::string &engine = "null-default") { + factory.registerValidator(engine, std::make_shared()); + factory.registerStorage(engine, std::make_shared("default")); + factory.setDefaultSchemaType(engine); } -// ============================================================================ -// Helper to register both storage + validator for an engine -// ============================================================================ -void registerEngine(SchemaServiceFactory &factory, - const std::string &engine, - const std::string &validator_name, - const std::string &storage_name) { - factory.registerValidator(engine, std::make_shared(validator_name)); - factory.registerStorage(engine, std::make_shared(storage_name)); -} +} // anonymous namespace // ============================================================================ -// Test Suite: setDefaultSchemaType +// Test Suite: Factory with JsonSchemaValidator // ============================================================================ -BOOST_AUTO_TEST_SUITE(SetDefaultSchemaType) +BOOST_AUTO_TEST_SUITE(FactoryWithJsonSchemaValidator) -BOOST_AUTO_TEST_CASE(throws_if_validator_not_registered) { +BOOST_AUTO_TEST_CASE(register_json_schema_validator) { SchemaServiceFactory factory; - factory.registerStorage("Engine", std::make_shared("S")); + auto json_validator = std::make_shared(); - BOOST_CHECK_THROW(factory.setDefaultSchemaType("Engine"), TraceException); + factory.registerValidator("json-schema", json_validator); + + ISchemaValidator &retrieved = factory.getValidator("json-schema"); + BOOST_TEST(retrieved.hasValidationCapability() == true); } -BOOST_AUTO_TEST_CASE(throws_if_storage_not_registered) { +BOOST_AUTO_TEST_CASE(validate_definition_through_factory) { SchemaServiceFactory factory; - factory.registerValidator("Engine", std::make_shared("V")); + auto json_validator = std::make_shared(); + factory.registerValidator("json-schema", json_validator); + auto ctx = makeTestLogContext(); + + ISchemaValidator &validator = factory.getValidator("json-schema"); + auto result = validator.validateDefinition("json", VALID_SCHEMA, ctx); - BOOST_CHECK_THROW(factory.setDefaultSchemaType("Engine"), TraceException); + BOOST_TEST(result.valid == true); + BOOST_TEST(result.errors.empty()); } -BOOST_AUTO_TEST_CASE(throws_if_neither_registered) { +BOOST_AUTO_TEST_CASE(validate_metadata_through_factory) { SchemaServiceFactory factory; + auto json_validator = std::make_shared(); + factory.registerValidator("json-schema", json_validator); + auto ctx = makeTestLogContext(); - BOOST_CHECK_THROW(factory.setDefaultSchemaType("Engine"), TraceException); + ISchemaValidator &validator = factory.getValidator("json-schema"); + + // Cache schema first + bool cached = validator.cacheSchema("test-schema", VALID_SCHEMA, "json", ctx); + BOOST_REQUIRE(cached); + + // Validate metadata + auto result = validator.validateMetadata("test-schema", "json", VALID_METADATA, ctx); + BOOST_TEST(result.valid == true); } -BOOST_AUTO_TEST_CASE(succeeds_when_both_registered) { +BOOST_AUTO_TEST_CASE(invalid_metadata_fails_through_factory) { SchemaServiceFactory factory; - registerEngine(factory, "Engine", "V", "S"); + auto json_validator = std::make_shared(); + factory.registerValidator("json-schema", json_validator); + auto ctx = makeTestLogContext(); + + ISchemaValidator &validator = factory.getValidator("json-schema"); + validator.cacheSchema("test-schema", VALID_SCHEMA, "json", ctx); + + auto result = validator.validateMetadata( + "test-schema", "json", INVALID_METADATA_MISSING_REQUIRED, ctx); - BOOST_CHECK_NO_THROW(factory.setDefaultSchemaType("Engine")); + BOOST_TEST(result.valid == false); + BOOST_TEST(!result.errors.empty()); } BOOST_AUTO_TEST_SUITE_END() // ============================================================================ -// Test Suite: Default Validator (updated for setDefaultSchemaType) +// Test Suite: Factory with NullSchemaValidator as Default // ============================================================================ -BOOST_AUTO_TEST_SUITE(DefaultValidator) +BOOST_AUTO_TEST_SUITE(FactoryWithNullDefault) -BOOST_AUTO_TEST_CASE(set_and_get_default_for_empty_engine) { +BOOST_AUTO_TEST_CASE(null_validator_as_default_for_legacy) { SchemaServiceFactory factory; - registerEngine(factory, "json-schema", "Default", "DefaultStorage"); - factory.setDefaultSchemaType("json-schema"); + auto json_validator = std::make_shared(); - auto *retrieved = dynamic_cast(&factory.getValidator("")); - BOOST_REQUIRE(retrieved != nullptr); - BOOST_TEST(retrieved->getName() == "Default"); -} + // Register json-schema engine (needs both validator + storage) + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); -BOOST_AUTO_TEST_CASE(unregistered_engine_returns_default) { - SchemaServiceFactory factory; - registerEngine(factory, "json-schema", "Default", "DefaultStorage"); - registerEngine(factory, "SpecificEngine", "Specific", "SpecificStorage"); - factory.setDefaultSchemaType("json-schema"); - - // Registered engine returns specific validator - auto *v_specific = dynamic_cast( - &factory.getValidator("SpecificEngine")); - BOOST_TEST(v_specific->getName() == "Specific"); - - // Unregistered engine returns default - auto *v_unknown = dynamic_cast( - &factory.getValidator("UnknownEngine")); - BOOST_TEST(v_unknown->getName() == "Default"); -} + // Register null-default as the fallback engine + registerNullDefault(factory); -BOOST_AUTO_TEST_CASE(native_engine_returns_default) { - SchemaServiceFactory factory; - registerEngine(factory, "json-schema", "Default", "DefaultStorage"); - factory.setDefaultSchemaType("json-schema"); + // json-schema engine gets real validation + BOOST_TEST(factory.getValidator("json-schema").hasValidationCapability() == true); - auto *retrieved = dynamic_cast( - &factory.getValidator("native")); - BOOST_REQUIRE(retrieved != nullptr); - BOOST_TEST(retrieved->getName() == "Default"); + // Empty/unknown engines fall back to the null-default engine + BOOST_TEST(factory.getValidator("").hasValidationCapability() == false); + BOOST_TEST(factory.getValidator("native").hasValidationCapability() == false); + BOOST_TEST(factory.getValidator("other").hasValidationCapability() == false); } -BOOST_AUTO_TEST_CASE(default_can_be_replaced) { +BOOST_AUTO_TEST_CASE(null_validator_accepts_anything) { SchemaServiceFactory factory; - registerEngine(factory, "engine-a", "Default1", "Storage1"); - registerEngine(factory, "engine-b", "Default2", "Storage2"); + auto ctx = makeTestLogContext(); + + // Register null-default as the fallback engine + registerNullDefault(factory); - factory.setDefaultSchemaType("engine-a"); - BOOST_TEST(dynamic_cast( - &factory.getValidator(""))->getName() == "Default1"); + // "legacy" is unregistered, resolves to the null-default + ISchemaValidator &validator = factory.getValidator("legacy"); - factory.setDefaultSchemaType("engine-b"); - BOOST_TEST(dynamic_cast( - &factory.getValidator(""))->getName() == "Default2"); + // Invalid JSON passes with null validator — no validation capability + auto result = validator.validateDefinition("json", "{ broken json }", ctx); + BOOST_TEST(result.valid == true); } -BOOST_AUTO_TEST_CASE(no_default_throws_for_unknown_engine) { +BOOST_AUTO_TEST_SUITE_END() + +// ============================================================================ +// Test Suite: Multiple Validators +// ============================================================================ + +BOOST_AUTO_TEST_SUITE(MultipleValidators) + +BOOST_AUTO_TEST_CASE(different_engines_use_different_validators) { SchemaServiceFactory factory; - registerEngine(factory, "SpecificEngine", "Specific", "SpecificStorage"); - // No default set + auto json_validator = std::make_shared(); + auto null_validator = std::make_shared(); - BOOST_CHECK_THROW(factory.getValidator("UnknownEngine"), TraceException); + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); + + factory.registerValidator("other", null_validator); + factory.registerStorage("other", std::make_shared("other")); + + // Set "other" (null) as the default fallback + factory.setDefaultSchemaType("other"); + + // json-schema has validation capability + BOOST_TEST(factory.getValidator("json-schema").hasValidationCapability() == true); + + // "other" explicitly registered as null + BOOST_TEST(factory.getValidator("other").hasValidationCapability() == false); + + // Unknown falls back to default ("other" / null) + BOOST_TEST(factory.getValidator("unknown").hasValidationCapability() == false); } -BOOST_AUTO_TEST_CASE(no_default_throws_for_empty_engine) { +BOOST_AUTO_TEST_CASE(each_engine_has_independent_cache) { SchemaServiceFactory factory; + auto validator1 = std::make_shared(); + auto validator2 = std::make_shared(); + + factory.registerValidator("Engine1", validator1); + factory.registerValidator("Engine2", validator2); + auto ctx = makeTestLogContext(); + + // Cache schema in Engine1's validator + factory.getValidator("Engine1").cacheSchema("shared-id", VALID_SCHEMA, "json", ctx); - BOOST_CHECK_THROW(factory.getValidator(""), TraceException); + // Engine1 can validate against it + auto result1 = factory.getValidator("Engine1").validateMetadata( + "shared-id", "json", VALID_METADATA, ctx); + BOOST_TEST(result1.valid == true); + + // Engine2 does NOT have it cached (separate instance) + auto result2 = factory.getValidator("Engine2").validateMetadata( + "shared-id", "json", VALID_METADATA, ctx); + BOOST_TEST(result2.valid == false); // Schema not found } BOOST_AUTO_TEST_SUITE_END() // ============================================================================ -// Test Suite: hasCustomValidator (one test updated) +// Test Suite: Realistic DataFed Configuration // ============================================================================ -BOOST_AUTO_TEST_SUITE(HasCustomValidator) +BOOST_AUTO_TEST_SUITE(RealisticDataFedConfiguration) -BOOST_AUTO_TEST_CASE(returns_false_for_unregistered) { +BOOST_AUTO_TEST_CASE(typical_datafed_setup) { + // Simulate how DataFed would configure the factory at startup SchemaServiceFactory factory; - BOOST_TEST(factory.hasCustomValidator("UnknownEngine") == false); -} + // NullSchemaValidator for legacy schemas (no validation) + auto null_validator = std::make_shared(); -BOOST_AUTO_TEST_CASE(returns_true_for_registered) { - SchemaServiceFactory factory; - auto mock = std::make_shared("Test"); + // JsonSchemaValidator for JSON Schema validation + auto json_validator = std::make_shared(); - factory.registerValidator("TestEngine", mock); + // Configure factory — register both engines with storage + validator + factory.registerValidator("json-schema", json_validator); + factory.registerStorage("json-schema", std::make_shared("json")); - BOOST_TEST(factory.hasCustomValidator("TestEngine") == true); -} + factory.registerValidator("legacy-null", null_validator); + factory.registerStorage("legacy-null", std::make_shared("legacy")); -BOOST_AUTO_TEST_CASE(default_does_not_count_as_custom) { - SchemaServiceFactory factory; - registerEngine(factory, "json-schema", "Default", "DefaultStorage"); - factory.setDefaultSchemaType("json-schema"); + // Legacy null engine is the fallback for empty/unknown engine strings + factory.setDefaultSchemaType("legacy-null"); - // Default is set, but no custom validator for these engines - BOOST_TEST(factory.hasCustomValidator("SomeEngine") == false); - BOOST_TEST(factory.hasCustomValidator("") == false); + auto ctx = makeTestLogContext(); - // The default engine itself IS registered, so it shows as custom - BOOST_TEST(factory.hasCustomValidator("json-schema") == true); -} + // Test: json-schema engine validates properly + { + ISchemaValidator &v = factory.getValidator("json-schema"); -BOOST_AUTO_TEST_CASE(case_sensitive_check) { - SchemaServiceFactory factory; - auto mock = std::make_shared("Test"); + auto def_result = v.validateDefinition("json", VALID_SCHEMA, ctx); + BOOST_TEST(def_result.valid == true); + + v.cacheSchema("user-schema", VALID_SCHEMA, "json", ctx); - factory.registerValidator("TestEngine", mock); + auto valid_result = v.validateMetadata("user-schema", "json", VALID_METADATA, ctx); + BOOST_TEST(valid_result.valid == true); - BOOST_TEST(factory.hasCustomValidator("TestEngine") == true); - BOOST_TEST(factory.hasCustomValidator("testengine") == false); - BOOST_TEST(factory.hasCustomValidator("TESTENGINE") == false); + auto invalid_result = v.validateMetadata( + "user-schema", "json", INVALID_METADATA_WRONG_TYPE, ctx); + BOOST_TEST(invalid_result.valid == false); + } + + // Test: Empty engine (legacy) skips validation + { + ISchemaValidator &v = factory.getValidator(""); + BOOST_TEST(v.hasValidationCapability() == false); + + auto result = v.validateDefinition("json", "not even json {{{", ctx); + BOOST_TEST(result.valid == true); // No validation = always passes + } + + // Test: "native" engine (legacy) skips validation + { + ISchemaValidator &v = factory.getValidator("native"); + BOOST_TEST(v.hasValidationCapability() == false); + } } BOOST_AUTO_TEST_SUITE_END() - diff --git a/tests/end-to-end/test_api_schema.py b/tests/end-to-end/test_api_schema.py index bd7eb240f..746911783 100755 --- a/tests/end-to-end/test_api_schema.py +++ b/tests/end-to-end/test_api_schema.py @@ -124,10 +124,10 @@ def test_schema_create_missing_definition(self): """Must provide either definition or definition_file.""" with self.assertRaises(Exception) as ctx: - self._df_api.schemaCreate("test_no_def", - description="test bad schema" - ) - + self._df_api.schemaCreate( + "test_no_def", + description="test bad schema" + ) self.assertIn("Must specify", str(ctx.exception)) def test_schema_create_both_definition_sources(self): @@ -138,7 +138,30 @@ def test_schema_create_both_definition_sources(self): "test_both_def", definition='{"type": "object", "properties": {}}', definition_file="/tmp/fake.json", - description="test bad schema" + ) + + self.assertIn("Cannot specify both", str(ctx.exception)) + + def test_schema_update_both_definition_sources(self): + """Cannot specify both definition and definition_file for schemaUpdate.""" + + with self.assertRaises(Exception) as ctx: + self._df_api.schemaUpdate( + "test_update_both_def", + definition='{"type": "object", "properties": {}}', + definition_file="/tmp/fake.json", + ) + + self.assertIn("Cannot specify both", str(ctx.exception)) + + def test_schema_revise_both_definition_sources(self): + """Cannot specify both definition and definition_file for schemaRevise.""" + + with self.assertRaises(Exception) as ctx: + self._df_api.schemaRevise( + "test_revise_both_def", + definition='{"type": "object", "properties": {}}', + definition_file="/tmp/fake.json", ) self.assertIn("Cannot specify both", str(ctx.exception)) @@ -383,6 +406,18 @@ def test_metadata_validate_requires_input(self): self.assertIn("Must specify", str(ctx.exception)) + def test_metadata_validate_metadata_file_cannot_be_opened(self): + """metadata_file set but file cannot be opened should raise expected error.""" + + bad_path = "/path/does/not/exist" + + with self.assertRaises(Exception) as ctx: + self._df_api.metadataValidate("any_schema", metadata_file=bad_path) + + # The client should surface a clear file-open error that includes the path. + self.assertIn("Could not open metadata file:", str(ctx.exception)) + self.assertIn(bad_path, str(ctx.exception)) + def test_schema_create_from_file(self): """Test creating a schema from a definition file.""" @@ -432,6 +467,7 @@ def tearDown(self): suite.addTest(TestDataFedPythonAPISchemaCRUD("test_metadata_validate_client_rejects_bad_json")) suite.addTest(TestDataFedPythonAPISchemaCRUD("test_metadata_validate_requires_input")) suite.addTest(TestDataFedPythonAPISchemaCRUD("test_schema_create_from_file")) + suite.addTest(TestDataFedPythonAPISchemaCRUD("test_metadata_validate_metadata_file_cannot_be_opened")) runner = unittest.TextTestRunner() result = runner.run(suite) sys.exit(not result.wasSuccessful())