diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c3e63f9..068ca0c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,39 @@ jobs: (cd codegen && gradle generateFixtures generateProtocolTests) git diff --exit-code -- examples protocol-tests + # Coverage measurement for the runtime (issue #48): the combined lcov + # report + rendered HTML land as a build artifact and the per-module + # summary prints in the log, so srcs<->tests gaps stop needing a manual + # audit. Measurement only — no threshold gate yet; add one once the + # baseline has soaked (same posture as benchmarks below). + coverage: + name: coverage (runtime, lcov) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: bazel-contrib/setup-bazel@0.19.0 + with: + bazelisk-cache: true + repository-cache: true + disk-cache: coverage + - name: bazel coverage + run: | + bazelisk coverage //... --config=ci --combined_report=lcov \ + --instrumentation_filter='//runtime[/:]' + - name: summarize and render + run: | + sudo apt-get update -q || true + sudo apt-get install -y -q lcov + REPORT="$(bazelisk info output_path)/_coverage/_coverage_report.dat" + lcov --summary "$REPORT" + lcov --list "$REPORT" + genhtml --output-directory coverage-html "$REPORT" + cp "$REPORT" coverage-html/coverage.lcov + - uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage-html + # Informational (PLAN Phase 7): publish numbers, no pass/fail threshold yet. benchmarks: name: benchmarks (informational) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2eabac..adcf0e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,5 +93,16 @@ via `git_override` until then. content-type/method/route) the way jsonrpc2's generated suite always did — including the previously-unasserted simpleRestJson `@pattern`-violation wire message. +- **Union x protocol conformance cells filled** (`protocol-tests/unions/`): + the cbor and jsonRpc2 union cells — previously reliant on coin-flip random + integration tests that only prove serde self-consistency — now pin the + wire subdocument for every union variant deterministically in all four + directions (client encode/decode, server decode/echo), plus the reject + cells (empty, multi-member, unknown-member, null-member) and the `__type` + discriminator tolerance. +- **Code-coverage tooling**: a `coverage` CI job runs + `bazel coverage --combined_report=lcov` over the runtime, prints the + per-module summary, and uploads the rendered HTML report as an artifact; + `make coverage` runs the same locally. Measurement only — no gate yet. [Unreleased]: https://github.com/aaylward/smithy-cpp/commits/main diff --git a/Makefile b/Makefile index e297c89f..4a9ce81d 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,14 @@ fuzz-smoke: consumer: cd examples/bazel-consumer && $(BAZEL) test //... && ./model-evolution-check.sh +# Line coverage for the runtime; the combined lcov report path prints at the +# end (render with genhtml, or read the CI job's artifact). +.PHONY: coverage +coverage: + $(BAZEL) coverage //... --combined_report=lcov \ + --instrumentation_filter='//runtime[/:]' + @echo "combined report: $$($(BAZEL) info output_path)/_coverage/_coverage_report.dat" + # Informational, never gates (PLAN Phase 7). .PHONY: benchmarks benchmarks: diff --git a/docs/development.md b/docs/development.md index daa3959a..471dd82a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,8 +24,9 @@ make verify-full # + sanitizers, fuzzer smoke runs, the consumer module, clang ``` Each aggregate is also callable piecemeal (`make test codegen goldens lint -sanitize fuzz-smoke consumer tidy benchmarks format`); the recipes mirror -`.github/workflows/ci.yml`, one target per job. The underlying commands: +sanitize fuzz-smoke consumer tidy coverage benchmarks format`); the recipes +mirror `.github/workflows/ci.yml`, one target per job. The underlying +commands: ```sh # C++ runtime: build + run all tests diff --git a/protocol-tests/unions/BUILD.bazel b/protocol-tests/unions/BUILD.bazel new file mode 100644 index 00000000..30a30b56 --- /dev/null +++ b/protocol-tests/unions/BUILD.bazel @@ -0,0 +1,40 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") + +# Union x protocol conformance cells (issue #48): union round-tripping was +# only pinned for simpleRestJson; the cbor and jsonRpc2 cells relied on +# seeded random integration tests that flip a coin on whether the union +# appears and can only prove serde self-consistency. These suites pin the +# wire subdocument for every SinkChoice variant deterministically — client +# encode/decode and server decode/echo — plus the reject cells and the +# __type discriminator tolerance. Hand-written (not generated), so they +# live outside the golden generated/ trees. + +cc_test( + name = "union_cbor_test", + size = "small", + srcs = ["union_cbor_test.cc"], + deps = [ + "//examples/roundtrip/rpc/generated:client", + "//examples/roundtrip/rpc/generated:server", + "//runtime:cbor", + "//runtime:client", + "//runtime:http", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "union_jsonrpc2_test", + size = "small", + srcs = ["union_jsonrpc2_test.cc"], + deps = [ + "//examples/roundtrip/jsonrpc/generated:client", + "//examples/roundtrip/jsonrpc/generated:server", + "//runtime:client", + "//runtime:http", + "//runtime:json", + "//runtime:protocol_test_support", + "@googletest//:gtest_main", + ], +) diff --git a/protocol-tests/unions/union_cbor_test.cc b/protocol-tests/unions/union_cbor_test.cc new file mode 100644 index 00000000..e05dd9e8 --- /dev/null +++ b/protocol-tests/unions/union_cbor_test.cc @@ -0,0 +1,277 @@ +// Union x rpcv2Cbor conformance (issue #48): until this suite, union +// round-tripping was only pinned for simpleRestJson — the cbor cell relied on +// the seeded random integration tests, which flip a coin on whether the union +// appears at all and can only prove serde self-consistency, not wire +// correctness. These tests pin the wire subdocument for every SinkChoice +// variant deterministically, in all four directions: client encode, client +// decode, server decode, server encode — plus the reject cells (empty, +// multi-member, unknown-member, null-member unions) and the __type +// discriminator tolerance on both sides. + +#include + +#include +#include +#include +#include + +#include "example/roundtrip/rpc/client.h" +#include "example/roundtrip/rpc/server.h" +#include "smithy/cbor/cbor.h" +#include "smithy/client/config.h" +#include "smithy/core/document.h" +#include "smithy/testing/protocol_test.h" + +namespace example::roundtrip::rpc { +namespace { + +smithy::Document TextChoiceDoc(const std::string& text) { + smithy::DocumentMap map; + map.emplace("text", smithy::Document(text)); + return smithy::Document(std::move(map)); +} + +smithy::Document CountChoiceDoc(std::int64_t count) { + smithy::DocumentMap map; + map.emplace("count", smithy::Document(count)); + return smithy::Document(std::move(map)); +} + +smithy::Document NestedChoiceDoc(const std::string& label, std::int64_t depth) { + smithy::DocumentMap nested; + nested.emplace("label", smithy::Document(label)); + nested.emplace("depth", smithy::Document(depth)); + smithy::DocumentMap map; + map.emplace("nested", smithy::Document(std::move(nested))); + return smithy::Document(std::move(map)); +} + +// A wire body for PutSinkRpc carrying only the members the union cell needs. +std::string BodyWithChoice(const smithy::Document& choice) { + smithy::DocumentMap sink; + sink.emplace("name", smithy::Document("n")); + sink.emplace("choice", choice); + smithy::DocumentMap body; + body.emplace("sinkId", smithy::Document("s1")); + body.emplace("sink", smithy::Document(std::move(sink))); + return smithy::cbor::Encode(smithy::Document(std::move(body))).ToString(); +} + +PutSinkRpcInput InputWithChoice(SinkChoice choice) { + KitchenSink sink; + sink.name = "n"; + sink.choice = std::move(choice); + PutSinkRpcInput input; + input.sinkId = "s1"; + input.sink = std::move(sink); + return input; +} + +// The choice subdocument of a captured PutSinkRpc request body. +smithy::Document ChoiceOf(const std::string& wire_body) { + auto doc = smithy::cbor::Decode(smithy::Blob::FromString(wire_body)); + EXPECT_TRUE(doc.ok()); + if (!doc.ok()) return smithy::Document(nullptr); + const smithy::Document* sink = doc->Find("sink"); + EXPECT_NE(sink, nullptr); + if (sink == nullptr) return smithy::Document(nullptr); + const smithy::Document* choice = sink->Find("choice"); + EXPECT_NE(choice, nullptr); + return choice == nullptr ? smithy::Document(nullptr) : *choice; +} + +class UnionCborClientTest : public testing::Test { + protected: + void SetUp() override { + transport_ = std::make_shared(); + smithy::DocumentMap ok_body; + ok_body.emplace("sinkId", smithy::Document("s1")); + transport_->next_response = smithy::http::HttpResponse{ + 200, {}, smithy::cbor::Encode(smithy::Document(std::move(ok_body))).ToString()}; + smithy::ClientConfig config; + config.retry.max_attempts = 1; + config.http_client = transport_; + auto client = RoundTripRpcClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + std::shared_ptr transport_; + std::unique_ptr client_; +}; + +TEST_F(UnionCborClientTest, EncodesEachVariantAsASingleMemberMap) { + const struct { + SinkChoice choice; + smithy::Document expected; + } cells[] = { + {SinkChoice::FromText("wire text"), TextChoiceDoc("wire text")}, + {SinkChoice::FromCount(-7), CountChoiceDoc(-7)}, + {SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "L"; + nested.depth = 3; + return nested; + }()), + NestedChoiceDoc("L", 3)}, + }; + for (const auto& cell : cells) { + ASSERT_TRUE(client_->PutSinkRpc(InputWithChoice(cell.choice)).ok()); + const smithy::Document choice = ChoiceOf(transport_->last_request.body); + ASSERT_TRUE(choice.is_map()); + EXPECT_EQ(choice.as_map().size(), 1u) << "a union must serialize exactly one member"; + EXPECT_EQ(choice, cell.expected); + EXPECT_EQ(transport_->last_request.headers.Get("smithy-protocol"), "rpc-v2-cbor"); + } +} + +TEST_F(UnionCborClientTest, DecodesEachVariantFromAResponse) { + const struct { + smithy::Document wire; + SinkChoice expected; + } cells[] = { + {TextChoiceDoc("from server"), SinkChoice::FromText("from server")}, + {CountChoiceDoc(42), SinkChoice::FromCount(42)}, + {NestedChoiceDoc("deep", 9), SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "deep"; + nested.depth = 9; + return nested; + }())}, + }; + for (const auto& cell : cells) { + transport_->next_response = smithy::http::HttpResponse{200, {}, BodyWithChoice(cell.wire)}; + const auto outcome = client_->PutSinkRpc(InputWithChoice(SinkChoice::FromCount(0))); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(outcome->sink.has_value()); + ASSERT_TRUE(outcome->sink->choice.has_value()); + EXPECT_EQ(*outcome->sink->choice, cell.expected); + } +} + +TEST_F(UnionCborClientTest, RejectsInvalidUnionsInResponses) { + const struct { + smithy::Document wire; + const char* why; + } cells[] = { + {smithy::Document(smithy::DocumentMap{}), "empty union"}, + {[] { + smithy::DocumentMap map; + map.emplace("text", smithy::Document("a")); + map.emplace("count", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + "two members set"}, + {[] { + smithy::DocumentMap map; + map.emplace("futureMember", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + "unknown member"}, + {[] { + smithy::DocumentMap map; + map.emplace("text", smithy::Document(nullptr)); + return smithy::Document(std::move(map)); + }(), + "null member"}, + {smithy::Document("not a map"), "non-map union"}, + }; + for (const auto& cell : cells) { + transport_->next_response = smithy::http::HttpResponse{200, {}, BodyWithChoice(cell.wire)}; + const auto outcome = client_->PutSinkRpc(InputWithChoice(SinkChoice::FromCount(0))); + EXPECT_FALSE(outcome.ok()) << cell.why; + } +} + +TEST_F(UnionCborClientTest, ToleratesATypeDiscriminatorNextToTheMember) { + smithy::DocumentMap map; + map.emplace("__type", smithy::Document("example.roundtrip#SinkChoice")); + map.emplace("text", smithy::Document("discriminated")); + transport_->next_response = + smithy::http::HttpResponse{200, {}, BodyWithChoice(smithy::Document(std::move(map)))}; + const auto outcome = client_->PutSinkRpc(InputWithChoice(SinkChoice::FromCount(0))); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + EXPECT_EQ(*outcome->sink->choice, SinkChoice::FromText("discriminated")); +} + +// --- Server side: the same cells through the generated request path ------- + +class RecordingHandler : public RoundTripRpcHandler { + public: + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) override { + last = input; + PutSinkRpcOutput output; + output.sinkId = input.sinkId; + output.sink = input.sink; // echo, so the response leg is exercised too + return output; + } + std::optional last; +}; + +class UnionCborServerTest : public testing::Test { + protected: + smithy::http::HttpResponse Send(const std::string& body) { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/service/RoundTripRpc/operation/PutSinkRpc"; + request.headers.Set("smithy-protocol", "rpc-v2-cbor"); + request.headers.Set("content-type", "application/cbor"); + request.body = body; + return server_.Handler()(request); + } + + std::shared_ptr handler_ = std::make_shared(); + RoundTripRpcServer server_{handler_}; +}; + +TEST_F(UnionCborServerTest, DecodesEachVariantAndEchoesItBack) { + const struct { + smithy::Document wire; + SinkChoice expected; + } cells[] = { + {TextChoiceDoc("to server"), SinkChoice::FromText("to server")}, + {CountChoiceDoc(-1), SinkChoice::FromCount(-1)}, + {NestedChoiceDoc("srv", 2), SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "srv"; + nested.depth = 2; + return nested; + }())}, + }; + for (const auto& cell : cells) { + const auto response = Send(BodyWithChoice(cell.wire)); + ASSERT_EQ(response.status, 200) << response.body; + ASSERT_TRUE(handler_->last.has_value()); + ASSERT_TRUE(handler_->last->sink.has_value() && handler_->last->sink->choice.has_value()); + EXPECT_EQ(*handler_->last->sink->choice, cell.expected); + + // The echoed response body carries the identical union subdocument. + EXPECT_EQ(ChoiceOf(response.body), cell.wire); + handler_->last.reset(); + } +} + +TEST_F(UnionCborServerTest, RejectsInvalidUnionsBeforeTheHandler) { + const smithy::Document invalid[] = { + smithy::Document(smithy::DocumentMap{}), + [] { + smithy::DocumentMap map; + map.emplace("text", smithy::Document("a")); + map.emplace("count", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + [] { + smithy::DocumentMap map; + map.emplace("futureMember", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + }; + for (const auto& wire : invalid) { + const auto response = Send(BodyWithChoice(wire)); + EXPECT_EQ(response.status, 400) << response.body; + EXPECT_FALSE(handler_->last.has_value()); + } +} + +} // namespace +} // namespace example::roundtrip::rpc diff --git a/protocol-tests/unions/union_jsonrpc2_test.cc b/protocol-tests/unions/union_jsonrpc2_test.cc new file mode 100644 index 00000000..79708f55 --- /dev/null +++ b/protocol-tests/unions/union_jsonrpc2_test.cc @@ -0,0 +1,282 @@ +// Union x jsonRpc2 conformance (issue #48): the second untested union cell. +// Same shape as union_cbor_test.cc — every SinkChoice variant pinned in all +// four directions plus the reject cells — but through the JSON-RPC 2.0 +// envelope, whose params/result nesting is exactly where a protocol +// generator could diverge from the shared serde. + +#include + +#include +#include +#include +#include + +#include "example/roundtrip/jsonrpc/client.h" +#include "example/roundtrip/jsonrpc/server.h" +#include "smithy/client/config.h" +#include "smithy/core/document.h" +#include "smithy/json/json.h" +#include "smithy/testing/protocol_test.h" + +namespace example::roundtrip::jsonrpc { +namespace { + +smithy::Document TextChoiceDoc(const std::string& text) { + smithy::DocumentMap map; + map.emplace("text", smithy::Document(text)); + return smithy::Document(std::move(map)); +} + +smithy::Document CountChoiceDoc(std::int64_t count) { + smithy::DocumentMap map; + map.emplace("count", smithy::Document(count)); + return smithy::Document(std::move(map)); +} + +smithy::Document NestedChoiceDoc(const std::string& label, std::int64_t depth) { + smithy::DocumentMap nested; + nested.emplace("label", smithy::Document(label)); + nested.emplace("depth", smithy::Document(depth)); + smithy::DocumentMap map; + map.emplace("nested", smithy::Document(std::move(nested))); + return smithy::Document(std::move(map)); +} + +smithy::Document PayloadWithChoice(const smithy::Document& choice) { + smithy::DocumentMap sink; + sink.emplace("name", smithy::Document("n")); + sink.emplace("choice", choice); + smithy::DocumentMap payload; + payload.emplace("sinkId", smithy::Document("s1")); + payload.emplace("sink", smithy::Document(std::move(sink))); + return smithy::Document(std::move(payload)); +} + +// A success envelope answering the client's fixed request id 1. +std::string ResultEnvelope(const smithy::Document& result) { + smithy::DocumentMap envelope; + envelope.emplace("jsonrpc", smithy::Document("2.0")); + envelope.emplace("id", smithy::Document(std::int64_t{1})); + envelope.emplace("result", result); + return smithy::json::Encode(smithy::Document(std::move(envelope))); +} + +PutSinkRpcInput InputWithChoice(SinkChoice choice) { + KitchenSink sink; + sink.name = "n"; + sink.choice = std::move(choice); + PutSinkRpcInput input; + input.sinkId = "s1"; + input.sink = std::move(sink); + return input; +} + +// The params.sink.choice subdocument of a captured JSON-RPC request. +smithy::Document RequestChoiceOf(const std::string& wire_body) { + auto doc = smithy::json::Decode(wire_body); + EXPECT_TRUE(doc.ok()); + if (!doc.ok()) return smithy::Document(nullptr); + const smithy::Document* params = doc->Find("params"); + EXPECT_NE(params, nullptr) << wire_body; + if (params == nullptr) return smithy::Document(nullptr); + const smithy::Document* sink = params->Find("sink"); + EXPECT_NE(sink, nullptr) << wire_body; + if (sink == nullptr) return smithy::Document(nullptr); + const smithy::Document* choice = sink->Find("choice"); + EXPECT_NE(choice, nullptr) << wire_body; + return choice == nullptr ? smithy::Document(nullptr) : *choice; +} + +class UnionJsonRpc2ClientTest : public testing::Test { + protected: + void SetUp() override { + transport_ = std::make_shared(); + smithy::DocumentMap ok_result; + ok_result.emplace("sinkId", smithy::Document("s1")); + transport_->next_response = + smithy::http::HttpResponse{200, {}, ResultEnvelope(smithy::Document(std::move(ok_result)))}; + smithy::ClientConfig config; + config.retry.max_attempts = 1; + config.http_client = transport_; + auto client = RoundTripJsonRpcClient::Create(std::move(config)); + ASSERT_TRUE(client.ok()) << client.error().message(); + client_ = std::make_unique(std::move(*client)); + } + + std::shared_ptr transport_; + std::unique_ptr client_; +}; + +TEST_F(UnionJsonRpc2ClientTest, EncodesEachVariantInsideTheEnvelopeParams) { + const struct { + SinkChoice choice; + smithy::Document expected; + } cells[] = { + {SinkChoice::FromText("wire text"), TextChoiceDoc("wire text")}, + {SinkChoice::FromCount(-7), CountChoiceDoc(-7)}, + {SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "L"; + nested.depth = 3; + return nested; + }()), + NestedChoiceDoc("L", 3)}, + }; + for (const auto& cell : cells) { + ASSERT_TRUE(client_->PutSinkRpc(InputWithChoice(cell.choice)).ok()); + const smithy::Document choice = RequestChoiceOf(transport_->last_request.body); + ASSERT_TRUE(choice.is_map()); + EXPECT_EQ(choice.as_map().size(), 1u) << "a union must serialize exactly one member"; + EXPECT_EQ(choice, cell.expected); + } +} + +TEST_F(UnionJsonRpc2ClientTest, DecodesEachVariantFromAResultEnvelope) { + const struct { + smithy::Document wire; + SinkChoice expected; + } cells[] = { + {TextChoiceDoc("from server"), SinkChoice::FromText("from server")}, + {CountChoiceDoc(42), SinkChoice::FromCount(42)}, + {NestedChoiceDoc("deep", 9), SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "deep"; + nested.depth = 9; + return nested; + }())}, + }; + for (const auto& cell : cells) { + transport_->next_response = + smithy::http::HttpResponse{200, {}, ResultEnvelope(PayloadWithChoice(cell.wire))}; + const auto outcome = client_->PutSinkRpc(InputWithChoice(SinkChoice::FromCount(0))); + ASSERT_TRUE(outcome.ok()) << outcome.error().message(); + ASSERT_TRUE(outcome->sink.has_value()); + ASSERT_TRUE(outcome->sink->choice.has_value()); + EXPECT_EQ(*outcome->sink->choice, cell.expected); + } +} + +TEST_F(UnionJsonRpc2ClientTest, RejectsInvalidUnionsInResultEnvelopes) { + const struct { + smithy::Document wire; + const char* why; + } cells[] = { + {smithy::Document(smithy::DocumentMap{}), "empty union"}, + {[] { + smithy::DocumentMap map; + map.emplace("text", smithy::Document("a")); + map.emplace("count", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + "two members set"}, + {[] { + smithy::DocumentMap map; + map.emplace("futureMember", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + "unknown member"}, + {smithy::Document("not a map"), "non-map union"}, + }; + for (const auto& cell : cells) { + transport_->next_response = + smithy::http::HttpResponse{200, {}, ResultEnvelope(PayloadWithChoice(cell.wire))}; + const auto outcome = client_->PutSinkRpc(InputWithChoice(SinkChoice::FromCount(0))); + EXPECT_FALSE(outcome.ok()) << cell.why; + } +} + +// --- Server side ---------------------------------------------------------- + +class RecordingHandler : public RoundTripJsonRpcHandler { + public: + smithy::Outcome PutSinkRpc(const PutSinkRpcInput& input) override { + last = input; + PutSinkRpcOutput output; + output.sinkId = input.sinkId; + output.sink = input.sink; + return output; + } + std::optional last; +}; + +class UnionJsonRpc2ServerTest : public testing::Test { + protected: + smithy::http::HttpResponse Send(const smithy::Document& params) { + smithy::DocumentMap envelope; + envelope.emplace("jsonrpc", smithy::Document("2.0")); + envelope.emplace("method", smithy::Document("PutSinkRpc")); + envelope.emplace("id", smithy::Document(std::int64_t{7})); + envelope.emplace("params", params); + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/"; + request.headers.Set("content-type", "application/json"); + request.body = smithy::json::Encode(smithy::Document(std::move(envelope))); + return server_.Handler()(request); + } + + std::shared_ptr handler_ = std::make_shared(); + RoundTripJsonRpcServer server_{handler_}; +}; + +TEST_F(UnionJsonRpc2ServerTest, DecodesEachVariantAndEchoesItBack) { + const struct { + smithy::Document wire; + SinkChoice expected; + } cells[] = { + {TextChoiceDoc("to server"), SinkChoice::FromText("to server")}, + {CountChoiceDoc(-1), SinkChoice::FromCount(-1)}, + {NestedChoiceDoc("srv", 2), SinkChoice::FromNested([] { + NestedConfig nested; + nested.label = "srv"; + nested.depth = 2; + return nested; + }())}, + }; + for (const auto& cell : cells) { + const auto response = Send(PayloadWithChoice(cell.wire)); + ASSERT_EQ(response.status, 200) << response.body; + ASSERT_TRUE(handler_->last.has_value()); + ASSERT_TRUE(handler_->last->sink.has_value() && handler_->last->sink->choice.has_value()); + EXPECT_EQ(*handler_->last->sink->choice, cell.expected); + + // The echoed result carries the identical union subdocument, under the + // envelope's result member and answering the request id. + auto body = smithy::json::Decode(response.body); + ASSERT_TRUE(body.ok()) << response.body; + EXPECT_EQ(body->Find("id")->as_int(), 7); + const smithy::Document* result = body->Find("result"); + ASSERT_NE(result, nullptr) << response.body; + EXPECT_EQ(*result->Find("sink")->Find("choice"), cell.wire); + handler_->last.reset(); + } +} + +TEST_F(UnionJsonRpc2ServerTest, RejectsInvalidUnionsBeforeTheHandler) { + const smithy::Document invalid[] = { + smithy::Document(smithy::DocumentMap{}), + [] { + smithy::DocumentMap map; + map.emplace("text", smithy::Document("a")); + map.emplace("count", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + [] { + smithy::DocumentMap map; + map.emplace("futureMember", smithy::Document(std::int64_t{1})); + return smithy::Document(std::move(map)); + }(), + }; + for (const auto& wire : invalid) { + const auto response = Send(PayloadWithChoice(wire)); + // jsonRpc2 answers protocol-layer failures as an error envelope on 200. + EXPECT_EQ(response.status, 200) << response.body; + auto body = smithy::json::Decode(response.body); + ASSERT_TRUE(body.ok()) << response.body; + EXPECT_NE(body->Find("error"), nullptr) << response.body; + EXPECT_FALSE(handler_->last.has_value()); + } +} + +} // namespace +} // namespace example::roundtrip::jsonrpc