diff --git a/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp b/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp new file mode 100644 index 000000000..0d45470aa --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/fdv2_change.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::data_model { + +struct FDv2Change { + std::string key; + std::variant, ItemDescriptor> object; +}; + +struct FDv2ChangeSet { + enum class Type { + kFull = 0, + kPartial = 1, + kNone = 2, + }; + + Type type; + std::vector changes; + Selector selector; +}; + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/data_model/selector.hpp b/libs/internal/include/launchdarkly/data_model/selector.hpp new file mode 100644 index 000000000..ae4478567 --- /dev/null +++ b/libs/internal/include/launchdarkly/data_model/selector.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace launchdarkly::data_model { + +/** + * Identifies a specific version of data in the LaunchDarkly backend, used to + * request incremental updates from a known point.

A selector is either + * empty or contains a version number and state string that + * were provided by a LaunchDarkly data source. Empty selectors signal that the + * client has no existing data and requires a full payload.

For SDK + * consumers implementing custom data sources: you should always use + * std::nullopt when constructing a ChangeSet. Non-empty selectors are + * set by LaunchDarkly's own data sources based on state received from the + * LaunchDarkly backend, and are not meaningful when constructed externally. + */ +struct Selector { + struct State { + std::int64_t version; + std::string state; + }; + + std::optional value; +}; + +} // namespace launchdarkly::data_model diff --git a/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp new file mode 100644 index 000000000..50ea455ad --- /dev/null +++ b/libs/internal/include/launchdarkly/serialization/json_fdv2_events.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include + +namespace launchdarkly { + +enum class IntentCode { kNone, kTransferFull, kTransferChanges, kUnknown }; + +struct ServerIntentPayload { + std::string id; + std::int64_t target; + IntentCode intent_code; + std::optional reason; +}; + +struct ServerIntent { + std::vector payloads; +}; + +struct PutObject { + std::int64_t version; + std::string kind; + std::string key; + boost::json::value object; +}; + +struct DeleteObject { + std::int64_t version; + std::string kind; + std::string key; +}; + +struct PayloadTransferred { + std::string state; + std::int64_t version; +}; + +struct Goodbye { + std::optional reason; +}; + +struct FDv2Error { + std::optional id; + std::string reason; +}; + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value); + +} // namespace launchdarkly diff --git a/libs/internal/src/CMakeLists.txt b/libs/internal/src/CMakeLists.txt index 3a1b9e171..44600638b 100644 --- a/libs/internal/src/CMakeLists.txt +++ b/libs/internal/src/CMakeLists.txt @@ -34,6 +34,7 @@ set(INTERNAL_SOURCES serialization/json_evaluation_reason.cpp serialization/value_mapping.cpp serialization/json_evaluation_result.cpp + serialization/json_fdv2_events.cpp serialization/json_sdk_data_set.cpp serialization/json_segment.cpp serialization/json_primitives.cpp diff --git a/libs/internal/src/serialization/json_fdv2_events.cpp b/libs/internal/src/serialization/json_fdv2_events.cpp new file mode 100644 index 000000000..63fba0d38 --- /dev/null +++ b/libs/internal/src/serialization/json_fdv2_events.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include + +namespace launchdarkly { + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_STRING(json_value); + + auto const& str = json_value.as_string(); + if (str == "none") { + return IntentCode::kNone; + } else if (str == "xfer-full") { + return IntentCode::kTransferFull; + } else if (str == "xfer-changes") { + return IntentCode::kTransferChanges; + } else { + return IntentCode::kUnknown; + } +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + ServerIntentPayload payload{}; + + PARSE_REQUIRED_FIELD(payload.id, obj, "id"); + PARSE_REQUIRED_FIELD(payload.target, obj, "target"); + PARSE_REQUIRED_FIELD(payload.intent_code, obj, "intentCode"); + PARSE_CONDITIONAL_FIELD(payload.reason, obj, "reason"); + + return payload; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + ServerIntent intent{}; + + PARSE_REQUIRED_FIELD(intent.payloads, obj, "payloads"); + + return intent; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + PutObject put{}; + + PARSE_REQUIRED_FIELD(put.version, obj, "version"); + PARSE_REQUIRED_FIELD(put.kind, obj, "kind"); + PARSE_REQUIRED_FIELD(put.key, obj, "key"); + + auto const& it = obj.find("object"); + if (it == obj.end()) { + return tl::make_unexpected(JsonError::kSchemaFailure); + } + put.object = it->value(); + + return put; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + DeleteObject del{}; + + PARSE_REQUIRED_FIELD(del.version, obj, "version"); + PARSE_REQUIRED_FIELD(del.kind, obj, "kind"); + PARSE_REQUIRED_FIELD(del.key, obj, "key"); + + return del; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag, + JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + PayloadTransferred transferred{}; + + PARSE_REQUIRED_FIELD(transferred.state, obj, "state"); + PARSE_REQUIRED_FIELD(transferred.version, obj, "version"); + + return transferred; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + Goodbye goodbye{}; + + PARSE_CONDITIONAL_FIELD(goodbye.reason, obj, "reason"); + + return goodbye; +} + +tl::expected, JsonError> tag_invoke( + boost::json::value_to_tag< + tl::expected, JsonError>> const& unused, + boost::json::value const& json_value) { + boost::ignore_unused(unused); + + REQUIRE_OBJECT(json_value); + auto const& obj = json_value.as_object(); + + FDv2Error error{}; + + PARSE_REQUIRED_FIELD(error.reason, obj, "reason"); + PARSE_CONDITIONAL_FIELD(error.id, obj, "id"); + + return error; +} + +} // namespace launchdarkly diff --git a/libs/internal/tests/fdv2_serialization_test.cpp b/libs/internal/tests/fdv2_serialization_test.cpp new file mode 100644 index 000000000..8083fa03b --- /dev/null +++ b/libs/internal/tests/fdv2_serialization_test.cpp @@ -0,0 +1,429 @@ +#include + +#include +#include + +using namespace launchdarkly; + +// ============================================================================ +// IntentCode +// ============================================================================ + +TEST(IntentCodeTests, DeserializesNone) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("none")")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(*result.value(), IntentCode::kNone); +} + +TEST(IntentCodeTests, DeserializesXferFull) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("xfer-full")")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(*result.value(), IntentCode::kTransferFull); +} + +TEST(IntentCodeTests, DeserializesXferChanges) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("xfer-changes")")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(*result.value(), IntentCode::kTransferChanges); +} + +TEST(IntentCodeTests, UnknownStringDeserializesToUnknown) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("bogus")")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(*result.value(), IntentCode::kUnknown); +} + +TEST(IntentCodeTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(42)")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(IntentCodeTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// ServerIntentPayload +// ============================================================================ + +TEST(ServerIntentPayloadTests, DeserializesValidPayload) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse( + R"({"id":"abc","target":1,"intentCode":"xfer-full","reason":"initial"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->id, "abc"); + ASSERT_EQ(result.value()->target, std::int64_t{1}); + ASSERT_EQ(result.value()->intent_code, IntentCode::kTransferFull); + ASSERT_TRUE(result.value()->reason); + ASSERT_EQ(*result.value()->reason, "initial"); +} + +TEST(ServerIntentPayloadTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("not-an-object")")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentPayloadTests, MissingIdReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"target":1,"intentCode":"none","reason":"r"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentPayloadTests, MissingIntentCodeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"id":"x","target":1,"reason":"r"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentPayloadTests, MissingTargetReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"id":"x","intentCode":"none","reason":"r"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentPayloadTests, MissingReasonSucceeds) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"id":"x","target":1,"intentCode":"none"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_FALSE(result.value()->reason); +} + +TEST(ServerIntentPayloadTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// ServerIntent +// ============================================================================ + +TEST(ServerIntentTests, DeserializesValidIntent) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(boost::json::parse( + R"({"payloads":[{"id":"abc","target":1,"intentCode":"xfer-full","reason":"r"}]})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->payloads.size(), 1u); + ASSERT_EQ(result.value()->payloads[0].id, "abc"); +} + +TEST(ServerIntentTests, DeserializesEmptyPayloads) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"payloads":[]})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_TRUE(result.value()->payloads.empty()); +} + +TEST(ServerIntentTests, MissingPayloadsReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"([])")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(ServerIntentTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// PutObject +// ============================================================================ + +TEST(PutObjectTests, DeserializesValidPutObject) { + auto result = boost::json::value_to< + tl::expected, JsonError>>(boost::json::parse( + R"({"version":5,"kind":"flag","key":"my-flag","object":{"on":true}})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->version, std::int64_t{5}); + ASSERT_EQ(result.value()->kind, "flag"); + ASSERT_EQ(result.value()->key, "my-flag"); + ASSERT_TRUE(result.value()->object.is_object()); +} + +TEST(PutObjectTests, MissingObjectReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1,"kind":"flag","key":"k"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PutObjectTests, MissingVersionReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"kind":"flag","key":"k","object":{}})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PutObjectTests, MissingKindReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1,"key":"k","object":{}})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PutObjectTests, MissingKeyReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1,"kind":"flag","object":{}})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PutObjectTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("string")")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PutObjectTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// DeleteObject +// ============================================================================ + +TEST(DeleteObjectTests, DeserializesValidDeleteObject) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":3,"kind":"segment","key":"my-seg"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->version, std::int64_t{3}); + ASSERT_EQ(result.value()->kind, "segment"); + ASSERT_EQ(result.value()->key, "my-seg"); +} + +TEST(DeleteObjectTests, MissingVersionReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"kind":"flag","key":"k"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(DeleteObjectTests, MissingKindReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1,"key":"k"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(DeleteObjectTests, MissingKeyReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1,"kind":"flag"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(DeleteObjectTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"([1,2,3])")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(DeleteObjectTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// PayloadTransferred +// ============================================================================ + +TEST(PayloadTransferredTests, DeserializesValidPayload) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"json({"state":"(p:abc:42)","version":42})json")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->state, std::string("(p:abc:42)")); + ASSERT_EQ(result.value()->version, std::int64_t{42}); +} + +TEST(PayloadTransferredTests, MissingStateReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"version":1})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PayloadTransferredTests, MissingVersionReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"state":"s"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PayloadTransferredTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"("string")")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(PayloadTransferredTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// Goodbye +// ============================================================================ + +TEST(GoodbyeTests, DeserializesWithReason) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"({"reason":"shutting down"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_TRUE(result.value()->reason); + ASSERT_EQ(*result.value()->reason, "shutting down"); +} + +TEST(GoodbyeTests, DeserializesWithoutReason) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"({})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_FALSE(result.value()->reason); +} + +TEST(GoodbyeTests, WrongTypeReturnsSchemaFailure) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"("goodbye")")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(GoodbyeTests, NullReturnsNullopt) { + auto result = + boost::json::value_to, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +} + +// ============================================================================ +// FDv2Error +// ============================================================================ + +TEST(FDv2ErrorTests, DeserializesWithRequiredFieldOnly) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"reason":"something went wrong"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->reason, "something went wrong"); + ASSERT_FALSE(result.value()->id); +} + +TEST(FDv2ErrorTests, DeserializesWithAllFields) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"reason":"oops","id":"err-42"})")); + ASSERT_TRUE(result); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->reason, "oops"); + ASSERT_TRUE(result.value()->id); + ASSERT_EQ(*result.value()->id, "err-42"); +} + +TEST(FDv2ErrorTests, MissingReasonReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"({"id":"e1"})")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(FDv2ErrorTests, WrongTypeReturnsSchemaFailure) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"([])")); + ASSERT_FALSE(result); + ASSERT_EQ(result.error(), JsonError::kSchemaFailure); +} + +TEST(FDv2ErrorTests, NullReturnsNullopt) { + auto result = boost::json::value_to< + tl::expected, JsonError>>( + boost::json::parse(R"(null)")); + ASSERT_TRUE(result); + ASSERT_FALSE(result.value()); +}