From 382c0d928590a6394665dc010f12c67f62424997 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:47:15 +0000 Subject: [PATCH] Testing & CI hardening phase 3: union x protocol conformance cells, coverage tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues issue #48 after #53 and #54: - Union x {rpcv2Cbor, jsonRpc2} conformance (protocol-tests/unions/): union round-tripping was only pinned for simpleRestJson; the other two cells 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 — a symmetric encode/decode bug passes them. The new suites pin the wire subdocument for every SinkChoice variant deterministically, in all four directions per protocol (client encode, client decode, server decode, server echo), plus the reject cells (empty, multi-member, unknown-member, null-member, non-map unions), the exactly-one-member wire invariant, and the __type discriminator tolerance. Both suites were mutation-checked: one expectation flipped per suite fails the suite, so a first-run pass is not vacuous. - Code-coverage tooling (issue #48's "module gaps only findable by manual srcs<->tests correspondence"): a coverage CI job runs bazel coverage with a combined lcov report over the runtime, prints the per-module summary in the log, and uploads the rendered HTML as a build artifact; `make coverage` runs the same locally. Measurement only, no threshold gate yet — same posture as the benchmarks job. Verified locally: bazel test //... green (74 tests; Boost/benchmark targets excluded per docs/development.md's proxy note, CI covers them), and bazel coverage --combined_report=lcov produces a 21-file runtime report. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012VFUppN1idpmU1UrbHkknZ --- .github/workflows/ci.yml | 33 +++ CHANGELOG.md | 11 + Makefile | 8 + docs/development.md | 5 +- protocol-tests/unions/BUILD.bazel | 40 +++ protocol-tests/unions/union_cbor_test.cc | 277 ++++++++++++++++++ protocol-tests/unions/union_jsonrpc2_test.cc | 282 +++++++++++++++++++ 7 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 protocol-tests/unions/BUILD.bazel create mode 100644 protocol-tests/unions/union_cbor_test.cc create mode 100644 protocol-tests/unions/union_jsonrpc2_test.cc 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