Files
EVEngine/test/schema_contract.cpp

231 lines
11 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "schema/SchemaRegistry.h"
#include <cstdint>
#include <string>
#include <utility>
namespace {
using eve::Value;
using eve::schema::FieldDefinition;
using eve::schema::SchemaDefinition;
using eve::schema::SchemaRegistry;
using eve::schema::ValueType;
FieldDefinition requiredField(std::string name, ValueType type) {
FieldDefinition field;
field.name = std::move(name);
field.type = type;
field.required = true;
return field;
}
SchemaDefinition versionedPayload(int version) {
SchemaDefinition schema;
schema.id = "test:migration";
schema.version = version;
schema.additionalProperties = false;
schema.fields.push_back(requiredField("number", ValueType::Integer));
if (version >= 2) schema.fields.push_back(requiredField("label", ValueType::String));
if (version >= 3) schema.fields.push_back(requiredField("ready", ValueType::Boolean));
return schema;
}
Value objectValue(int number) {
Value::Object object;
object.emplace("number", Value(static_cast<std::int64_t>(number)));
return Value(std::move(object));
}
bool registrationSucceeded(eve::Result<eve::schema::SchemaRegistrationStatus> result) {
return result.ok() && result.value() == eve::schema::SchemaRegistrationStatus::Registered;
}
} // namespace
TEST_CASE("schema.migration.multiHopCompatibilityAndCanonicalOutput") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(1))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(2))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(3))));
auto first =
SchemaRegistry::registerMigration("test:migration", 1, 2, [](const Value& input) -> eve::Result<Value> {
const auto* object = input.getIf<Value::Object>();
if (!object)
return eve::Result<Value>::failure(
eve::Diagnostic::error(eve::DiagnosticCode::ParseError, "expected object"));
Value::Object next = *object;
next.emplace("label", Value("legacy"));
return eve::Result<Value>::success(Value(std::move(next)));
});
REQUIRE(first.ok());
auto second =
SchemaRegistry::registerMigration("test:migration", 2, 3, [](const Value& input) -> eve::Result<Value> {
const auto* object = input.getIf<Value::Object>();
if (!object)
return eve::Result<Value>::failure(
eve::Diagnostic::error(eve::DiagnosticCode::ParseError, "expected object"));
Value::Object next = *object;
next.emplace("ready", Value(true));
return eve::Result<Value>::success(Value(std::move(next)));
});
REQUIRE(second.ok());
auto compatibility = SchemaRegistry::queryCompatibility("test:migration", 1, 3);
REQUIRE(compatibility.ok());
CHECK(!compatibility.value().exact());
CHECK_EQ(compatibility.value().versions, std::vector<int>({1, 2, 3}));
const Value input = objectValue(7);
auto migrated = SchemaRegistry::migrate("test:migration", 1, 3, input);
REQUIRE(migrated.ok());
CHECK_EQ(input, objectValue(7));
const auto* result = migrated.value().getIf<Value::Object>();
REQUIRE(result != nullptr);
CHECK_EQ(result->at("label"), Value("legacy"));
CHECK_EQ(result->at("ready"), Value(true));
auto json = SchemaRegistry::migrateJson("test:migration", 1, 3, R"({"number":7})");
REQUIRE(json.ok());
CHECK_EQ(json.value(), std::string(R"({"label":"legacy","number":7,"ready":true})"));
}
TEST_CASE("schema.migration.missingUnknownAndDowngradeAreStructuredFailures") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(1))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(3))));
auto missing = SchemaRegistry::queryCompatibility("test:migration", 1, 3);
CHECK(!missing.ok());
CHECK(static_cast<int>(missing.error()->code()) == static_cast<int>(eve::DiagnosticCode::Unsupported));
auto unknown = SchemaRegistry::queryCompatibility("test:migration", 1, 99);
CHECK(!unknown.ok());
CHECK(static_cast<int>(unknown.error()->code()) == static_cast<int>(eve::DiagnosticCode::UnknownVersion));
auto downgrade = SchemaRegistry::queryCompatibility("test:migration", 3, 1);
CHECK(!downgrade.ok());
CHECK(static_cast<int>(downgrade.error()->code()) == static_cast<int>(eve::DiagnosticCode::Unsupported));
}
TEST_CASE("schema.migration.rejectsCyclesAndDowngradeEdgesWithoutRegistryMutation") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(1))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(2))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(3))));
REQUIRE(SchemaRegistry::registerMigration("test:migration", 1, 2, [](const Value& input) {
return eve::Result<Value>::success(input);
}).ok());
auto cycle = SchemaRegistry::registerMigration(
"test:migration", 2, 1, [](const Value& input) { return eve::Result<Value>::success(input); });
CHECK(!cycle.ok());
CHECK(static_cast<int>(cycle.error()->code()) == static_cast<int>(eve::DiagnosticCode::Unsupported));
auto self = SchemaRegistry::registerMigration(
"test:migration", 2, 2, [](const Value& input) { return eve::Result<Value>::success(input); });
CHECK(!self.ok());
CHECK(static_cast<int>(self.error()->code()) == static_cast<int>(eve::DiagnosticCode::Conflict));
auto path = SchemaRegistry::queryCompatibility("test:migration", 1, 2);
REQUIRE(path.ok());
CHECK_EQ(path.value().versions, std::vector<int>({1, 2}));
}
TEST_CASE("schema.migration.callbackFailurePreservesInputAndRegistry") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(1))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(2))));
REQUIRE(SchemaRegistry::registerMigration("test:migration", 1, 2, [](const Value&) -> eve::Result<Value> {
return eve::Result<Value>::failure(
eve::Diagnostic::error(eve::DiagnosticCode::Failed, "injected migration failure"));
}).ok());
const Value input = objectValue(9);
const int before = SchemaRegistry::versionCount();
auto failed = SchemaRegistry::migrate("test:migration", 1, 2, input);
CHECK(!failed.ok());
CHECK(static_cast<int>(failed.error()->code()) == static_cast<int>(eve::DiagnosticCode::Failed));
CHECK_EQ(input, objectValue(9));
CHECK_EQ(SchemaRegistry::versionCount(), before);
CHECK(SchemaRegistry::queryCompatibility("test:migration", 1, 2).ok());
}
TEST_CASE("schema.migration.invalidOutputPreservesInputAndRegistry") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(1))));
REQUIRE(registrationSucceeded(SchemaRegistry::registerVersioned(versionedPayload(2))));
REQUIRE(SchemaRegistry::registerMigration("test:migration", 1, 2, [](const Value& input) {
return eve::Result<Value>::success(input);
}).ok());
const Value input = objectValue(11);
const int before = SchemaRegistry::versionCount();
auto failed = SchemaRegistry::migrate("test:migration", 1, 2, input);
CHECK(!failed.ok());
CHECK(static_cast<int>(failed.error()->code()) == static_cast<int>(eve::DiagnosticCode::ParseError));
CHECK_EQ(input, objectValue(11));
CHECK_EQ(SchemaRegistry::versionCount(), before);
CHECK(SchemaRegistry::queryCompatibility("test:migration", 1, 2).ok());
}
TEST_CASE("schema.validation.supportsNestedItemsRefsAndDiscriminatedUnion") {
SchemaRegistry::clear();
const std::string address = R"({
"id":"test:address", "schemaVersion":1, "additionalProperties":false,
"fields":[{"name":"city","type":"string","required":true}]
})";
REQUIRE(registrationSucceeded(SchemaRegistry::registerFromJsonVersioned(address)));
const std::string profile = R"({
"id":"test:profile", "schemaVersion":1, "additionalProperties":false,
"fields":[
{"name":"address","ref":"test:address","refVersion":1,"required":true},
{"name":"items","type":"array","minItems":1,"items":{
"type":"object","additionalProperties":false,
"fields":[{"name":"id","type":"string","required":true}]}},
{"name":"shape","discriminator":"kind","discriminatorMapping":{"circle":0,"square":1},"union":[
{"type":"object","additionalProperties":false,"fields":[
{"name":"kind","type":"string","required":true,"enum":["circle"]},
{"name":"radius","type":"number","required":true,"minimum":0}]},
{"type":"object","additionalProperties":false,"fields":[
{"name":"kind","type":"string","required":true,"enum":["square"]},
{"name":"side","type":"number","required":true,"minimum":0}]}
]}
]
})";
REQUIRE(registrationSucceeded(SchemaRegistry::registerFromJsonVersioned(profile)));
const std::string valid =
R"({"address":{"city":"Osaka"},"items":[{"id":"a"}],"shape":{"kind":"circle","radius":2}})";
CHECK(SchemaRegistry::validate("test:profile", 1, valid).empty());
const auto invalid = SchemaRegistry::validate(
"test:profile", 1, R"({"address":{"city":4},"items":[{}],"shape":{"kind":"triangle","radius":-1}})");
CHECK(!invalid.empty());
CHECK_EQ(invalid[0].path, std::string("/address/city"));
CHECK_EQ(invalid[0].code, std::string("type"));
}
TEST_CASE("schema.validation.rejectsKeywordsOutsideEveSubset") {
SchemaRegistry::clear();
auto status = SchemaRegistry::registerFromJsonVersioned(
R"({"id":"test:unsupported","schemaVersion":1,"patternProperties":{}})");
CHECK(!status.ok());
CHECK_EQ(status.code(), eve::StatusCode::Failed);
REQUIRE(status.error() != nullptr);
CHECK(status.error()->message().find("patternProperties") != std::string::npos);
}
TEST_CASE("schema.validation.detectsSchemaReferenceCycle") {
SchemaRegistry::clear();
REQUIRE(registrationSucceeded(SchemaRegistry::registerFromJsonVersioned(
R"({"id":"test:a","schemaVersion":1,"fields":[{"name":"b","ref":"test:b"}]})")));
REQUIRE(registrationSucceeded(SchemaRegistry::registerFromJsonVersioned(
R"({"id":"test:b","schemaVersion":1,"fields":[{"name":"a","ref":"test:a"}]})")));
const auto errors = SchemaRegistry::validate("test:a", 1, R"({"b":{"a":{}}})");
REQUIRE(!errors.empty());
CHECK_EQ(errors.back().code, std::string("schema_ref_cycle"));
}