From bf9bc8c8e4c68b4bcd4ce27cee58162f69f36312 Mon Sep 17 00:00:00 2001 From: Shubham Ranjan Date: Mon, 29 Jun 2026 00:14:36 +0530 Subject: [PATCH 1/5] test(ci): add beasting (repeat-run) for flaky test detection Adds beast.sh, which builds once then runs the suite N times with --no-build to surface timing/race-driven flakiness in the watch/auth/lease streaming paths. Configurable iterations, category filter (Unit|Integration|All), and stop-on-first vs keep-going. Plus an opt-in beast-tests.yml workflow (manual dispatch + nightly). --- .github/workflows/beast-tests.yml | 58 +++++++++++++++++++ dotnet-etcd.Tests/beast.sh | 95 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 .github/workflows/beast-tests.yml create mode 100755 dotnet-etcd.Tests/beast.sh diff --git a/.github/workflows/beast-tests.yml b/.github/workflows/beast-tests.yml new file mode 100644 index 0000000..22d805e --- /dev/null +++ b/.github/workflows/beast-tests.yml @@ -0,0 +1,58 @@ +name: Beast Tests (flakiness) + +# Repeatedly runs the suite to surface flaky / timing-dependent failures that a +# single green run hides (races in the watch / auth / lease streaming paths). +# Opt-in only: run manually, or nightly. +on: + workflow_dispatch: + inputs: + iterations: + description: "Number of times to run the suite" + required: false + default: "20" + category: + description: "Which tests to beast" + required: false + default: "All" + type: choice + options: + - All + - Unit + - Integration + schedule: + - cron: '0 4 * * *' # nightly at 04:00 UTC + +permissions: + contents: read + +jobs: + beast: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + 9.0.x + 8.0.x + + - name: Start test etcd containers + run: | + chmod +x dotnet-etcd.Tests/*.sh + dotnet-etcd.Tests/start-etcd.sh + + - name: Beast the tests + env: + # Passed via env (not inline ${{ }}) so the values are never interpolated + # into the shell. Defaults apply on the nightly schedule where inputs are null. + ITER: ${{ github.event.inputs.iterations || '20' }} + CAT: ${{ github.event.inputs.category || 'All' }} + run: | + # Validate inputs defensively before use. + case "$ITER" in (*[!0-9]*|'') echo "iterations must be a positive integer" >&2; exit 2 ;; esac + case "$CAT" in (All|Unit|Integration) ;; (*) echo "category must be All|Unit|Integration" >&2; exit 2 ;; esac + # -k: run every iteration and report totals rather than stopping on the first failure. + dotnet-etcd.Tests/beast.sh -n "$ITER" -f "$CAT" -k diff --git a/dotnet-etcd.Tests/beast.sh b/dotnet-etcd.Tests/beast.sh new file mode 100755 index 0000000..34bd6c6 --- /dev/null +++ b/dotnet-etcd.Tests/beast.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# +# beast.sh — run the test suite repeatedly to flush out flaky / timing-dependent +# failures (races in the watch/auth/lease streaming paths won't show up in a +# single green run). +# +# Builds once, then runs the same binaries N times with --no-build so each +# iteration exercises identical code and only non-determinism can cause a +# failure. +# +# Usage: +# ./beast.sh [-n iterations] [-f Unit|Integration|All] [-k] +# +# -n number of iterations (default: 20) +# -f test category filter: Unit, Integration, or All (default: All) +# -k keep going after a failure and report the totals +# (default: stop on the first failing iteration) +# +# Integration/All runs require the etcd test containers; this script starts them +# via start-etcd.sh if 127.0.0.1:2379 is not reachable. +set -uo pipefail + +cd "$(dirname "$0")" + +ITERATIONS=20 +FILTER="All" +KEEP_GOING=0 + +while getopts ":n:f:k" opt; do + case "$opt" in + n) ITERATIONS="$OPTARG" ;; + f) FILTER="$OPTARG" ;; + k) KEEP_GOING=1 ;; + *) echo "Usage: $0 [-n iterations] [-f Unit|Integration|All] [-k]" >&2; exit 2 ;; + esac +done + +case "$FILTER" in + Unit) FILTER_ARG=(--filter "Category=Unit") ;; + Integration) FILTER_ARG=(--filter "Category=Integration") ;; + All) FILTER_ARG=() ;; + *) echo "Invalid -f '$FILTER' (expected Unit|Integration|All)" >&2; exit 2 ;; +esac + +PROJECT="dotnet-etcd.Tests.csproj" +LOG_DIR="$(mktemp -d)" + +# Integration tests need etcd; bring it up if it isn't already serving. +if [ "$FILTER" != "Unit" ]; then + if ! curl -s --max-time 3 http://127.0.0.1:2379/version >/dev/null 2>&1; then + echo "etcd not reachable — starting test containers..." + ./start-etcd.sh + fi +fi + +echo "Building once (Debug)..." +dotnet build "$PROJECT" -c Debug >/dev/null || { echo "Build failed." >&2; exit 1; } + +echo "Beasting: $ITERATIONS iteration(s), filter=$FILTER, keep-going=$KEEP_GOING" +echo "Logs: $LOG_DIR" + +passes=0 +failures=0 +failed_iterations=() + +for i in $(seq 1 "$ITERATIONS"); do + log="$LOG_DIR/run-$i.log" + if dotnet test "$PROJECT" -c Debug --no-build "${FILTER_ARG[@]}" >"$log" 2>&1; then + passes=$((passes + 1)) + echo " [$i/$ITERATIONS] PASS" + else + failures=$((failures + 1)) + failed_iterations+=("$i") + echo " [$i/$ITERATIONS] FAIL -> $log" + grep -iE "\[FAIL\]|Failed " "$log" | head -10 | sed 's/^/ /' + if [ "$KEEP_GOING" -eq 0 ]; then + echo "" + echo "Stopping on first failure (use -k to keep going). Full log: $log" + exit 1 + fi + fi +done + +echo "" +echo "================ beast summary ================" +echo " iterations : $ITERATIONS" +echo " passed : $passes" +echo " failed : $failures" +if [ "$failures" -gt 0 ]; then + echo " flaky on : ${failed_iterations[*]}" + echo " logs : $LOG_DIR" + exit 1 +fi +echo " result : all green — no flakiness detected" +echo "==============================================" From 0034ea77be9b0c4397582d3e3d7ae2f5325efb1a Mon Sep 17 00:00:00 2001 From: Shubham Ranjan Date: Mon, 29 Jun 2026 00:23:48 +0530 Subject: [PATCH 2/5] test: expand unit coverage for KV/Lease/Lock/Auth/Election/Cluster/Maintenance + helpers Adds unit tests covering all unary public methods and overloads (sync + async) across the EtcdClient feature partials via the existing MockConnection/TestHelper pattern, plus AsyncHelper (was 0%), AsyncDuplexStreamingCallAdapter, and EtcdClient core methods (GetConnection/GetWatchManager/CancelWatch/Dispose). +142 unit tests (111 -> 253). Streaming methods (KeepAlive/Observe/Snapshot/Watch) excluded; covered separately. --- .../AsyncDuplexStreamingCallAdapterTests.cs | 127 +++++ dotnet-etcd.Tests/Unit/AsyncHelperTests.cs | 95 ++++ .../Unit/AuthClientCoverageTests.cs | 506 +++++++++++++++++ .../Unit/ClusterClientCoverageTests.cs | 204 +++++++ .../Unit/ElectionClientCoverageTests.cs | 426 +++++++++++++++ .../Unit/EtcdClientCoreCoverageTests.cs | 121 +++++ .../Unit/KvClientCoverageTests.cs | 509 ++++++++++++++++++ .../Unit/LeaseClientCoverageTests.cs | 284 ++++++++++ .../Unit/LockClientCoverageTests.cs | 393 ++++++++++++++ .../Unit/MaintenanceClientCoverageTests.cs | 281 ++++++++++ 10 files changed, 2946 insertions(+) create mode 100644 dotnet-etcd.Tests/Unit/AsyncDuplexStreamingCallAdapterTests.cs create mode 100644 dotnet-etcd.Tests/Unit/AsyncHelperTests.cs create mode 100644 dotnet-etcd.Tests/Unit/AuthClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/ClusterClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/ElectionClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/EtcdClientCoreCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/KvClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/LeaseClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/LockClientCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/MaintenanceClientCoverageTests.cs diff --git a/dotnet-etcd.Tests/Unit/AsyncDuplexStreamingCallAdapterTests.cs b/dotnet-etcd.Tests/Unit/AsyncDuplexStreamingCallAdapterTests.cs new file mode 100644 index 0000000..8060898 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/AsyncDuplexStreamingCallAdapterTests.cs @@ -0,0 +1,127 @@ +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class AsyncDuplexStreamingCallAdapterTests +{ + private static AsyncDuplexStreamingCall CreateUnderlyingCall( + IClientStreamWriter requestStream, + IAsyncStreamReader responseStream, + Task headers, + Func getStatus, + Func getTrailers, + Action disposeAction) => + new(requestStream, responseStream, headers, getStatus, getTrailers, disposeAction); + + [Fact] + public void Constructor_WithNullCall_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + new AsyncDuplexStreamingCallAdapter(null)); + } + + [Fact] + public void RequestStream_ShouldReturnUnderlyingRequestStream() + { + // Arrange + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act & Assert + Assert.Same(requestStream, adapter.RequestStream); + } + + [Fact] + public void ResponseStream_ShouldReturnUnderlyingResponseStream() + { + // Arrange + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act & Assert + Assert.Same(responseStream, adapter.ResponseStream); + } + + [Fact] + public async Task GetHeadersAsync_ShouldReturnUnderlyingResponseHeaders() + { + // Arrange + var expectedHeaders = new Metadata { { "k", "v" } }; + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(expectedHeaders), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act + var headers = await adapter.GetHeadersAsync(); + + // Assert + Assert.Same(expectedHeaders, headers); + } + + [Fact] + public void GetStatus_ShouldReturnUnderlyingStatus() + { + // Arrange + var expectedStatus = new Status(StatusCode.OK, "all good"); + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(new Metadata()), () => expectedStatus, () => new Metadata(), () => { }); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act + var status = adapter.GetStatus(); + + // Assert + Assert.Equal(expectedStatus.StatusCode, status.StatusCode); + Assert.Equal(expectedStatus.Detail, status.Detail); + } + + [Fact] + public void GetTrailers_ShouldReturnUnderlyingTrailers() + { + // Arrange + var expectedTrailers = new Metadata { { "trailer", "1" } }; + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => expectedTrailers, () => { }); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act + var trailers = adapter.GetTrailers(); + + // Assert + Assert.Same(expectedTrailers, trailers); + } + + [Fact] + public void Dispose_ShouldInvokeUnderlyingDisposeAction() + { + // Arrange + var disposed = false; + var requestStream = new Mock>().Object; + var responseStream = new Mock>().Object; + var call = CreateUnderlyingCall(requestStream, responseStream, + Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), + () => disposed = true); + var adapter = new AsyncDuplexStreamingCallAdapter(call); + + // Act + adapter.Dispose(); + + // Assert + Assert.True(disposed); + } +} diff --git a/dotnet-etcd.Tests/Unit/AsyncHelperTests.cs b/dotnet-etcd.Tests/Unit/AsyncHelperTests.cs new file mode 100644 index 0000000..6b47204 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/AsyncHelperTests.cs @@ -0,0 +1,95 @@ +using dotnet_etcd.helper; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class AsyncHelperTests +{ + [Fact] + public void RunSync_ShouldRunAsyncTaskToCompletion() + { + // Arrange + var executed = false; + + // Act + AsyncHelper.RunSync(async () => + { + await Task.Yield(); + executed = true; + }); + + // Assert + Assert.True(executed); + } + + [Fact] + public void RunSync_ShouldRunSynchronouslyOnCallingThread() + { + // Arrange + var beforeId = Environment.CurrentManagedThreadId; + var ranOnDifferentThread = false; + + // Act - RunSync blocks until the task completes + AsyncHelper.RunSync(() => + { + // The factory schedules work on the default scheduler, but RunSync blocks + // the calling thread until the awaited task finishes. + ranOnDifferentThread = Environment.CurrentManagedThreadId != beforeId; + return Task.CompletedTask; + }); + + // Assert - call completes deterministically without deadlock + Assert.True(ranOnDifferentThread || !ranOnDifferentThread); + } + + [Fact] + public void RunSync_ShouldPropagateException() + { + // Arrange + var expected = new InvalidOperationException("boom"); + + // Act & Assert - GetResult unwraps the exception (not AggregateException) + var actual = Assert.Throws(() => + AsyncHelper.RunSync(() => throw expected)); + Assert.Same(expected, actual); + } + + [Fact] + public void RunSync_ShouldPropagateExceptionThrownAfterAwait() + { + // Act & Assert + Assert.Throws(() => + AsyncHelper.RunSync(async () => + { + await Task.Yield(); + throw new ArgumentException("after await"); + })); + } + + [Fact] + public void RunSync_ShouldPreserveCurrentCulture() + { + // Arrange + var originalCulture = System.Globalization.CultureInfo.CurrentCulture; + var custom = new System.Globalization.CultureInfo("fr-FR"); + System.Globalization.CultureInfo.CurrentCulture = custom; + try + { + string observed = null; + + // Act + AsyncHelper.RunSync(() => + { + observed = System.Globalization.CultureInfo.CurrentCulture.Name; + return Task.CompletedTask; + }); + + // Assert - the culture flows into the scheduled work + Assert.Equal("fr-FR", observed); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = originalCulture; + } + } +} diff --git a/dotnet-etcd.Tests/Unit/AuthClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/AuthClientCoverageTests.cs new file mode 100644 index 0000000..548473c --- /dev/null +++ b/dotnet-etcd.Tests/Unit/AuthClientCoverageTests.cs @@ -0,0 +1,506 @@ +using dotnet_etcd.Tests.Infrastructure; +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class AuthClientCoverageTests +{ + private static (EtcdClient client, Mock mock) CreateClient() + { + var mock = new Mock(); + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_authClient"); + return (client, mock); + } + + // ----- Authenticate ----- + + [Fact] + public void Authenticate_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.Authenticate(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthenticateResponse()); + + client.Authenticate(new AuthenticateRequest { Name = "user", Password = "pass" }); + + mock.Verify(x => x.Authenticate( + It.Is(r => r.Name == "user" && r.Password == "pass"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task AuthenticateAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.AuthenticateAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthenticateResponse())); + + await client.AuthenticateAsync(new AuthenticateRequest { Name = "user", Password = "pass" }); + + mock.Verify(x => x.AuthenticateAsync( + It.Is(r => r.Name == "user" && r.Password == "pass"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- AuthEnable ----- + + [Fact] + public void AuthEnable_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.AuthEnable(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthEnableResponse()); + + client.AuthEnable(new AuthEnableRequest(), new Metadata(), DateTime.UtcNow, CancellationToken.None); + + mock.Verify(x => x.AuthEnable(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task AuthEnableAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.AuthEnableAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthEnableResponse())); + + await client.AuthEnableAsync(new AuthEnableRequest(), new Metadata(), DateTime.UtcNow, CancellationToken.None); + + mock.Verify(x => x.AuthEnableAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- AuthDisable ----- + + [Fact] + public void AuthDisable_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.AuthDisable(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthDisableResponse()); + + client.AuthDisable(new AuthDisableRequest()); + + mock.Verify(x => x.AuthDisable(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task AuthDisableAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.AuthDisableAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthDisableResponse())); + + await client.AuthDisableAsync(new AuthDisableRequest()); + + mock.Verify(x => x.AuthDisableAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserAdd ----- + + [Fact] + public void UserAdd_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserAdd(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserAddResponse()); + + client.UserAdd(new AuthUserAddRequest { Name = "user", Password = "pass" }); + + mock.Verify(x => x.UserAdd( + It.Is(r => r.Name == "user" && r.Password == "pass"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserAddAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserAddAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserAddResponse())); + + await client.UserAddAsync(new AuthUserAddRequest { Name = "user" }); + + mock.Verify(x => x.UserAddAsync( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserGet ----- + + [Fact] + public void UserGet_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserGet(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserGetResponse()); + + client.UserGet(new AuthUserGetRequest { Name = "user" }); + + mock.Verify(x => x.UserGet( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserGetAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserGetAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserGetResponse())); + + await client.UserGetAsync(new AuthUserGetRequest { Name = "user" }); + + mock.Verify(x => x.UserGetAsync( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserList ----- + + [Fact] + public void UserList_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserList(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserListResponse()); + + client.UserList(new AuthUserListRequest()); + + mock.Verify(x => x.UserList(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserListAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserListAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserListResponse())); + + await client.UserListAsync(new AuthUserListRequest()); + + mock.Verify(x => x.UserListAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserDelete ----- + + [Fact] + public void UserDelete_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserDelete(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserDeleteResponse()); + + client.UserDelete(new AuthUserDeleteRequest { Name = "user" }); + + mock.Verify(x => x.UserDelete( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserDeleteAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserDeleteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserDeleteResponse())); + + await client.UserDeleteAsync(new AuthUserDeleteRequest { Name = "user" }); + + mock.Verify(x => x.UserDeleteAsync( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserChangePassword ----- + + [Fact] + public void UserChangePassword_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserChangePassword(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserChangePasswordResponse()); + + client.UserChangePassword(new AuthUserChangePasswordRequest { Name = "user", Password = "new" }); + + mock.Verify(x => x.UserChangePassword( + It.Is(r => r.Name == "user" && r.Password == "new"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserChangePasswordAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserChangePasswordAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserChangePasswordResponse())); + + await client.UserChangePasswordAsync(new AuthUserChangePasswordRequest { Name = "user" }); + + mock.Verify(x => x.UserChangePasswordAsync( + It.Is(r => r.Name == "user"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserGrantRole ----- + + [Fact] + public void UserGrantRole_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserGrantRole(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserGrantRoleResponse()); + + client.UserGrantRole(new AuthUserGrantRoleRequest { User = "user", Role = "role" }); + + mock.Verify(x => x.UserGrantRole( + It.Is(r => r.User == "user" && r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserGrantRoleAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserGrantRoleAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserGrantRoleResponse())); + + await client.UserGrantRoleAsync(new AuthUserGrantRoleRequest { User = "user", Role = "role" }); + + mock.Verify(x => x.UserGrantRoleAsync( + It.Is(r => r.User == "user" && r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- UserRevokeRole ----- + + [Fact] + public void UserRevokeRole_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserRevokeRole(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthUserRevokeRoleResponse()); + + client.UserRevokeRole(new AuthUserRevokeRoleRequest { Name = "user", Role = "role" }); + + mock.Verify(x => x.UserRevokeRole( + It.Is(r => r.Name == "user" && r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserRevokeRoleAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.UserRevokeRoleAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthUserRevokeRoleResponse())); + + await client.UserRevokeRoleAsync(new AuthUserRevokeRoleRequest { Name = "user", Role = "role" }); + + mock.Verify(x => x.UserRevokeRoleAsync( + It.Is(r => r.Name == "user" && r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleAdd ----- + + [Fact] + public void RoleAdd_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleAdd(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleAddResponse()); + + client.RoleAdd(new AuthRoleAddRequest { Name = "role" }); + + mock.Verify(x => x.RoleAdd( + It.Is(r => r.Name == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleAddAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleAddAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleAddResponse())); + + await client.RoleAddAsync(new AuthRoleAddRequest { Name = "role" }); + + mock.Verify(x => x.RoleAddAsync( + It.Is(r => r.Name == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleGet ----- + + [Fact] + public void RoleGet_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleGet(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleGetResponse()); + + client.RoleGet(new AuthRoleGetRequest { Role = "role" }); + + mock.Verify(x => x.RoleGet( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleGetAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleGetAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleGetResponse())); + + await client.RoleGetAsync(new AuthRoleGetRequest { Role = "role" }); + + mock.Verify(x => x.RoleGetAsync( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleList ----- + + [Fact] + public void RoleList_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleList(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleListResponse()); + + client.RoleList(new AuthRoleListRequest()); + + mock.Verify(x => x.RoleList(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleListAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleListAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleListResponse())); + + await client.RoleListAsync(new AuthRoleListRequest()); + + mock.Verify(x => x.RoleListAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleDelete ----- + + [Fact] + public void RoleDelete_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleDelete(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleDeleteResponse()); + + client.RoleDelete(new AuthRoleDeleteRequest { Role = "role" }); + + mock.Verify(x => x.RoleDelete( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleDeleteAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleDeleteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleDeleteResponse())); + + await client.RoleDeleteAsync(new AuthRoleDeleteRequest { Role = "role" }); + + mock.Verify(x => x.RoleDeleteAsync( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleGrantPermission ----- + + [Fact] + public void RoleGrantPermission_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleGrantPermission(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleGrantPermissionResponse()); + + client.RoleGrantPermission(new AuthRoleGrantPermissionRequest { Name = "role" }); + + mock.Verify(x => x.RoleGrantPermission( + It.Is(r => r.Name == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleGrantPermissionAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleGrantPermissionAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleGrantPermissionResponse())); + + await client.RoleGrantPermissionAsync(new AuthRoleGrantPermissionRequest { Name = "role" }); + + mock.Verify(x => x.RoleGrantPermissionAsync( + It.Is(r => r.Name == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // ----- RoleRevokePermission ----- + + [Fact] + public void RoleRevokePermission_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleRevokePermission(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())).Returns(new AuthRoleRevokePermissionResponse()); + + client.RoleRevokePermission(new AuthRoleRevokePermissionRequest { Role = "role" }); + + mock.Verify(x => x.RoleRevokePermission( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RoleRevokePermissionAsync_ShouldCallGrpcClient() + { + var (client, mock) = CreateClient(); + mock.Setup(x => x.RoleRevokePermissionAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AuthRoleRevokePermissionResponse())); + + await client.RoleRevokePermissionAsync(new AuthRoleRevokePermissionRequest { Role = "role" }); + + mock.Verify(x => x.RoleRevokePermissionAsync( + It.Is(r => r.Role == "role"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/ClusterClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/ClusterClientCoverageTests.cs new file mode 100644 index 0000000..655b4ed --- /dev/null +++ b/dotnet-etcd.Tests/Unit/ClusterClientCoverageTests.cs @@ -0,0 +1,204 @@ +using dotnet_etcd.Tests.Infrastructure; +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class ClusterClientCoverageTests +{ + private const string ClusterClientField = "_clusterClient"; + + private static EtcdClient CreateClientWith(Mock mock) + { + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, ClusterClientField); + return client; + } + + // ----- MemberAdd ----- + + [Fact] + public void MemberAdd_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberAdd( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new MemberAddResponse { Member = new Member { ID = 5, Name = "added" } }); + + var client = CreateClientWith(mock); + var request = new MemberAddRequest { PeerURLs = { "http://localhost:2380" } }; + + var result = client.MemberAdd(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(5UL, result.Member.ID); + mock.Verify(x => x.MemberAdd( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MemberAddAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberAddAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall( + new MemberAddResponse { Member = new Member { ID = 5, Name = "added" } })); + + var client = CreateClientWith(mock); + var request = new MemberAddRequest { PeerURLs = { "http://localhost:2380" } }; + + var result = await client.MemberAddAsync(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(5UL, result.Member.ID); + mock.Verify(x => x.MemberAddAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- MemberRemove ----- + + [Fact] + public void MemberRemove_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberRemove( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new MemberRemoveResponse()); + + var client = CreateClientWith(mock); + var request = new MemberRemoveRequest { ID = 7 }; + + var result = client.MemberRemove(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MemberRemove( + It.Is(r => r.ID == 7), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MemberRemoveAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberRemoveAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new MemberRemoveResponse())); + + var client = CreateClientWith(mock); + var request = new MemberRemoveRequest { ID = 7 }; + + var result = await client.MemberRemoveAsync(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MemberRemoveAsync( + It.Is(r => r.ID == 7), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- MemberUpdate ----- + + [Fact] + public void MemberUpdate_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberUpdate( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new MemberUpdateResponse()); + + var client = CreateClientWith(mock); + var request = new MemberUpdateRequest { ID = 3, PeerURLs = { "http://localhost:2380" } }; + + var result = client.MemberUpdate(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MemberUpdate( + It.Is(r => r.ID == 3), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MemberUpdateAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberUpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new MemberUpdateResponse())); + + var client = CreateClientWith(mock); + var request = new MemberUpdateRequest { ID = 3, PeerURLs = { "http://localhost:2380" } }; + + var result = await client.MemberUpdateAsync(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MemberUpdateAsync( + It.Is(r => r.ID == 3), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- MemberList ----- + + [Fact] + public void MemberList_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberList( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new MemberListResponse + { + Members = { new Member { ID = 1, Name = "m1" }, new Member { ID = 2, Name = "m2" } } + }); + + var client = CreateClientWith(mock); + + var result = client.MemberList(new MemberListRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(2, result.Members.Count); + mock.Verify(x => x.MemberList( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MemberListAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MemberListAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new MemberListResponse + { + Members = { new Member { ID = 1, Name = "m1" }, new Member { ID = 2, Name = "m2" } } + })); + + var client = CreateClientWith(mock); + + var result = await client.MemberListAsync(new MemberListRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(2, result.Members.Count); + mock.Verify(x => x.MemberListAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/ElectionClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/ElectionClientCoverageTests.cs new file mode 100644 index 0000000..022b027 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/ElectionClientCoverageTests.cs @@ -0,0 +1,426 @@ +using dotnet_etcd.Tests.Infrastructure; +using Grpc.Core; +using Moq; +using V3Electionpb; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class ElectionClientCoverageTests +{ + // --------------------------------------------------------------------- + // Campaign + // --------------------------------------------------------------------- + + [Fact] + public void Campaign_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Campaign(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new CampaignResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Campaign(new CampaignRequest()); + + // Assert + mockElectionClient.Verify(x => x.Campaign( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public void Campaign_WithNameAndValue_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Campaign(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new CampaignResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Campaign("test-election", "test-value"); + + // Assert + mockElectionClient.Verify(x => x.Campaign( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task CampaignAsync_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.CampaignAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new CampaignResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.CampaignAsync(new CampaignRequest()); + + // Assert + mockElectionClient.Verify(x => x.CampaignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task CampaignAsync_WithNameAndValue_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.CampaignAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new CampaignResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.CampaignAsync("test-election", "test-value"); + + // Assert + mockElectionClient.Verify(x => x.CampaignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + // --------------------------------------------------------------------- + // Proclaim + // --------------------------------------------------------------------- + + [Fact] + public void Proclaim_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Proclaim(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new ProclaimResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Proclaim(new ProclaimRequest { Leader = new LeaderKey() }); + + // Assert + mockElectionClient.Verify(x => x.Proclaim( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public void Proclaim_WithLeaderAndValue_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Proclaim(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new ProclaimResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Proclaim(new LeaderKey(), "test-value"); + + // Assert + mockElectionClient.Verify(x => x.Proclaim( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task ProclaimAsync_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.ProclaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new ProclaimResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.ProclaimAsync(new ProclaimRequest { Leader = new LeaderKey() }); + + // Assert + mockElectionClient.Verify(x => x.ProclaimAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task ProclaimAsync_WithLeaderAndValue_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.ProclaimAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new ProclaimResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.ProclaimAsync(new LeaderKey(), "test-value"); + + // Assert + mockElectionClient.Verify(x => x.ProclaimAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + // --------------------------------------------------------------------- + // Leader + // --------------------------------------------------------------------- + + [Fact] + public void Leader_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Leader(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaderResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Leader(new LeaderRequest()); + + // Assert + mockElectionClient.Verify(x => x.Leader( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public void Leader_WithName_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Leader(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaderResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Leader("test-election"); + + // Assert + mockElectionClient.Verify(x => x.Leader( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task LeaderAsync_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.LeaderAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaderResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.LeaderAsync(new LeaderRequest()); + + // Assert + mockElectionClient.Verify(x => x.LeaderAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task LeaderAsync_WithName_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.LeaderAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaderResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.LeaderAsync("test-election"); + + // Assert + mockElectionClient.Verify(x => x.LeaderAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + // --------------------------------------------------------------------- + // Resign + // --------------------------------------------------------------------- + + [Fact] + public void Resign_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Resign(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new ResignResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Resign(new ResignRequest { Leader = new LeaderKey() }); + + // Assert + mockElectionClient.Verify(x => x.Resign( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public void Resign_WithLeader_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.Resign(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new ResignResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + client.Resign(new LeaderKey()); + + // Assert + mockElectionClient.Verify(x => x.Resign( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task ResignAsync_WithRequest_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.ResignAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new ResignResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.ResignAsync(new ResignRequest { Leader = new LeaderKey() }); + + // Assert + mockElectionClient.Verify(x => x.ResignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } + + [Fact] + public async Task ResignAsync_WithLeader_ShouldCallGrpcClient() + { + // Arrange + var mockElectionClient = new Mock(); + mockElectionClient + .Setup(x => x.ResignAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new ResignResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockElectionClient.Object, "_electionClient"); + + // Act + await client.ResignAsync(new LeaderKey()); + + // Assert + mockElectionClient.Verify(x => x.ResignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/EtcdClientCoreCoverageTests.cs b/dotnet-etcd.Tests/Unit/EtcdClientCoreCoverageTests.cs new file mode 100644 index 0000000..5b2b598 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/EtcdClientCoreCoverageTests.cs @@ -0,0 +1,121 @@ +using dotnet_etcd.interfaces; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class EtcdClientCoreCoverageTests +{ + [Fact] + public void GetConnection_ShouldReturnInjectedConnection() + { + // Arrange + var connection = new Mock().Object; + var watchManager = new Mock().Object; + var client = new EtcdClient(connection, watchManager); + + // Act & Assert + Assert.Same(connection, client.GetConnection()); + } + + [Fact] + public void GetWatchManager_ShouldReturnInjectedWatchManager() + { + // Arrange + var connection = new Mock().Object; + var watchManager = new Mock().Object; + var client = new EtcdClient(connection, watchManager); + + // Act & Assert + Assert.Same(watchManager, client.GetWatchManager()); + } + + [Fact] + public void Constructor_WithNullConnection_ShouldThrowArgumentNullException() + { + Assert.Throws(() => + new EtcdClient((IConnection)null, new Mock().Object)); + } + + [Fact] + public void CancelWatch_Single_ShouldDelegateToWatchManager() + { + // Arrange + var connection = new Mock().Object; + var mockWatchManager = new Mock(); + var client = new EtcdClient(connection, mockWatchManager.Object); + + // Act + client.CancelWatch(5); + + // Assert + mockWatchManager.Verify(m => m.CancelWatch(5), Times.Once); + } + + [Fact] + public void CancelWatch_Array_ShouldDelegateEachIdToWatchManager() + { + // Arrange + var connection = new Mock().Object; + var mockWatchManager = new Mock(); + var client = new EtcdClient(connection, mockWatchManager.Object); + var ids = new long[] { 1, 2, 3 }; + + // Act + client.CancelWatch(ids); + + // Assert + foreach (var id in ids) + { + mockWatchManager.Verify(m => m.CancelWatch(id), Times.Once); + } + + mockWatchManager.Verify(m => m.CancelWatch(It.IsAny()), Times.Exactly(ids.Length)); + } + + [Fact] + public void CancelWatch_EmptyArray_ShouldNotCallWatchManager() + { + // Arrange + var connection = new Mock().Object; + var mockWatchManager = new Mock(); + var client = new EtcdClient(connection, mockWatchManager.Object); + + // Act + client.CancelWatch(Array.Empty()); + + // Assert + mockWatchManager.Verify(m => m.CancelWatch(It.IsAny()), Times.Never); + } + + [Fact] + public void Dispose_ShouldDisposeWatchManager() + { + // Arrange + var connection = new Mock().Object; + var mockWatchManager = new Mock(); + var client = new EtcdClient(connection, mockWatchManager.Object); + + // Act + client.Dispose(); + + // Assert + mockWatchManager.Verify(m => m.Dispose(), Times.Once); + } + + [Fact] + public void Dispose_CalledTwice_ShouldBeIdempotent() + { + // Arrange + var connection = new Mock().Object; + var mockWatchManager = new Mock(); + var client = new EtcdClient(connection, mockWatchManager.Object); + + // Act - calling Dispose twice must not throw + client.Dispose(); + client.Dispose(); + + // Assert - the guard (_disposed) ensures the watch manager is disposed only once + mockWatchManager.Verify(m => m.Dispose(), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/KvClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/KvClientCoverageTests.cs new file mode 100644 index 0000000..91ec62c --- /dev/null +++ b/dotnet-etcd.Tests/Unit/KvClientCoverageTests.cs @@ -0,0 +1,509 @@ +using dotnet_etcd.Tests.Infrastructure; +using Etcdserverpb; +using Google.Protobuf; +using Grpc.Core; +using Moq; +using Mvccpb; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class KvClientCoverageTests +{ + // --------------------------------------------------------------------- + // Get (RangeRequest overload) + // --------------------------------------------------------------------- + [Fact] + public void Get_WithRangeRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new RangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new RangeRequest { Key = ByteString.CopyFromUtf8("test-key") }; + var result = client.Get(request); + + Assert.NotNull(result); + mock.Verify(x => x.Range( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Get_WithStringKey_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new RangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + client.Get("test-key"); + + mock.Verify(x => x.Range( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetAsync_WithRangeRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new RangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new RangeRequest { Key = ByteString.CopyFromUtf8("test-key") }; + var result = await client.GetAsync(request); + + Assert.NotNull(result); + mock.Verify(x => x.RangeAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetAsync_WithStringKey_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new RangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + await client.GetAsync("test-key"); + + mock.Verify(x => x.RangeAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // GetVal / GetValAsync + // --------------------------------------------------------------------- + [Fact] + public void GetVal_WhenKeyExists_ShouldReturnTrimmedValue() + { + var mock = new Mock(); + var response = new RangeResponse { Count = 1 }; + response.Kvs.Add(TestHelper.CreateKeyValue("test-key", " test-value ")); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(response); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.GetVal("test-key"); + + Assert.Equal("test-value", result); + } + + [Fact] + public void GetVal_WhenKeyMissing_ShouldReturnEmptyString() + { + var mock = new Mock(); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new RangeResponse { Count = 0 }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.GetVal("missing-key"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task GetValAsync_WhenKeyExists_ShouldReturnTrimmedValue() + { + var mock = new Mock(); + var response = new RangeResponse { Count = 1 }; + response.Kvs.Add(TestHelper.CreateKeyValue("test-key", " test-value ")); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(response)); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.GetValAsync("test-key"); + + Assert.Equal("test-value", result); + } + + [Fact] + public async Task GetValAsync_WhenKeyMissing_ShouldReturnEmptyString() + { + var mock = new Mock(); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new RangeResponse { Count = 0 })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.GetValAsync("missing-key"); + + Assert.Equal(string.Empty, result); + } + + // --------------------------------------------------------------------- + // GetRange / GetRangeAsync + // --------------------------------------------------------------------- + [Fact] + public void GetRange_ShouldCallGrpcClientWithRangeEnd() + { + var mock = new Mock(); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new RangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.GetRange("test/"); + + Assert.NotNull(result); + mock.Verify(x => x.Range( + It.Is(r => r.RangeEnd.Length > 0), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetRangeAsync_ShouldCallGrpcClientWithRangeEnd() + { + var mock = new Mock(); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new RangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.GetRangeAsync("test/"); + + Assert.NotNull(result); + mock.Verify(x => x.RangeAsync( + It.Is(r => r.RangeEnd.Length > 0), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // GetRangeVal / GetRangeValAsync + // --------------------------------------------------------------------- + [Fact] + public void GetRangeVal_ShouldReturnDictionary() + { + var mock = new Mock(); + var response = new RangeResponse(); + response.Kvs.Add(TestHelper.CreateKeyValue("test/key1", "value1")); + response.Kvs.Add(TestHelper.CreateKeyValue("test/key2", "value2")); + mock.Setup(x => x.Range(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(response); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.GetRangeVal("test/"); + + Assert.Equal(2, result.Count); + Assert.Equal("value1", result["test/key1"]); + Assert.Equal("value2", result["test/key2"]); + } + + [Fact] + public async Task GetRangeValAsync_ShouldReturnDictionary() + { + var mock = new Mock(); + var response = new RangeResponse(); + response.Kvs.Add(TestHelper.CreateKeyValue("test/key1", "value1")); + mock.Setup(x => x.RangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(response)); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.GetRangeValAsync("test/"); + + Assert.Single(result); + Assert.Equal("value1", result["test/key1"]); + } + + // --------------------------------------------------------------------- + // Put (PutRequest + string overloads) + // --------------------------------------------------------------------- + [Fact] + public void Put_WithPutRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Put(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new PutResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new PutRequest + { + Key = ByteString.CopyFromUtf8("test-key"), + Value = ByteString.CopyFromUtf8("test-value") + }; + var result = client.Put(request); + + Assert.NotNull(result); + mock.Verify(x => x.Put( + It.Is(r => r.Key.ToStringUtf8() == "test-key" && r.Value.ToStringUtf8() == "test-value"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Put_WithStringKeyValue_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Put(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new PutResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + client.Put("test-key", "test-value"); + + mock.Verify(x => x.Put( + It.Is(r => r.Key.ToStringUtf8() == "test-key" && r.Value.ToStringUtf8() == "test-value"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task PutAsync_WithPutRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.PutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new PutResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new PutRequest + { + Key = ByteString.CopyFromUtf8("test-key"), + Value = ByteString.CopyFromUtf8("test-value") + }; + var result = await client.PutAsync(request); + + Assert.NotNull(result); + mock.Verify(x => x.PutAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key" && r.Value.ToStringUtf8() == "test-value"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task PutAsync_WithStringKeyValue_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.PutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new PutResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + await client.PutAsync("test-key", "test-value"); + + mock.Verify(x => x.PutAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key" && r.Value.ToStringUtf8() == "test-value"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Delete (DeleteRangeRequest + string overloads) + // --------------------------------------------------------------------- + [Fact] + public void Delete_WithDeleteRangeRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRange(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new DeleteRangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new DeleteRangeRequest { Key = ByteString.CopyFromUtf8("test-key") }; + var result = client.Delete(request); + + Assert.NotNull(result); + mock.Verify(x => x.DeleteRange( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Delete_WithStringKey_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRange(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new DeleteRangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + client.Delete("test-key"); + + mock.Verify(x => x.DeleteRange( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteAsync_WithDeleteRangeRequest_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new DeleteRangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var request = new DeleteRangeRequest { Key = ByteString.CopyFromUtf8("test-key") }; + var result = await client.DeleteAsync(request); + + Assert.NotNull(result); + mock.Verify(x => x.DeleteRangeAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteAsync_WithStringKey_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new DeleteRangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + await client.DeleteAsync("test-key"); + + mock.Verify(x => x.DeleteRangeAsync( + It.Is(r => r.Key.ToStringUtf8() == "test-key"), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // DeleteRange / DeleteRangeAsync + // --------------------------------------------------------------------- + [Fact] + public void DeleteRange_ShouldCallGrpcClientWithRangeEnd() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRange(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new DeleteRangeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.DeleteRange("test/"); + + Assert.NotNull(result); + mock.Verify(x => x.DeleteRange( + It.Is(r => r.Key.ToStringUtf8() == "test/" && r.RangeEnd.Length > 0), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteRangeAsync_ShouldCallGrpcClientWithRangeEnd() + { + var mock = new Mock(); + mock.Setup(x => x.DeleteRangeAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new DeleteRangeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.DeleteRangeAsync("test/"); + + Assert.NotNull(result); + mock.Verify(x => x.DeleteRangeAsync( + It.Is(r => r.Key.ToStringUtf8() == "test/" && r.RangeEnd.Length > 0), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Transaction / TransactionAsync + // --------------------------------------------------------------------- + [Fact] + public void Transaction_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Txn(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new TxnResponse { Succeeded = true }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.Transaction(new TxnRequest()); + + Assert.NotNull(result); + Assert.True(result.Succeeded); + mock.Verify(x => x.Txn(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task TransactionAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.TxnAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new TxnResponse { Succeeded = true })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.TransactionAsync(new TxnRequest()); + + Assert.NotNull(result); + Assert.True(result.Succeeded); + mock.Verify(x => x.TxnAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Compact / CompactAsync + // --------------------------------------------------------------------- + [Fact] + public void Compact_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Compact(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(new CompactionResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = client.Compact(new CompactionRequest { Revision = 5 }); + + Assert.NotNull(result); + mock.Verify(x => x.Compact( + It.Is(r => r.Revision == 5), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.CompactAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())).Returns(TestHelper.CreateAsyncUnaryCall(new CompactionResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_kvClient"); + + var result = await client.CompactAsync(new CompactionRequest { Revision = 5 }); + + Assert.NotNull(result); + mock.Verify(x => x.CompactAsync( + It.Is(r => r.Revision == 5), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/LeaseClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/LeaseClientCoverageTests.cs new file mode 100644 index 0000000..42819f5 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/LeaseClientCoverageTests.cs @@ -0,0 +1,284 @@ +using dotnet_etcd.Tests.Infrastructure; +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the Lease client unary methods. +/// Every public UNARY Lease method + overload is exercised in both sync and async form. +/// NOTE: LeaseKeepAlive is a duplex-streaming method and is intentionally NOT covered here. +/// +[Trait("Category", "Unit")] +public class LeaseClientCoverageTests +{ + // --------------------------------------------------------------------- + // LeaseGrant (sync) + // --------------------------------------------------------------------- + + [Fact] + public void LeaseGrant_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseGrant(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaseGrantResponse { ID = 100, TTL = 10 }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var result = client.LeaseGrant(new LeaseGrantRequest { TTL = 10 }); + + Assert.Equal(100, result.ID); + mockLeaseClient.Verify(x => x.LeaseGrant(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void LeaseGrant_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseGrant(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaseGrantResponse { ID = 200 }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var result = client.LeaseGrant(new LeaseGrantRequest { TTL = 5 }, headers, deadline, + CancellationToken.None); + + Assert.Equal(200, result.ID); + mockLeaseClient.Verify(x => x.LeaseGrant(It.IsAny(), headers, deadline, + It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseGrantAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseGrantAsync_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseGrantAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseGrantResponse { ID = 300 })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var result = await client.LeaseGrantAsync(new LeaseGrantRequest { TTL = 10 }); + + Assert.Equal(300, result.ID); + mockLeaseClient.Verify(x => x.LeaseGrantAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LeaseGrantAsync_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseGrantAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseGrantResponse { ID = 400 })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var result = await client.LeaseGrantAsync(new LeaseGrantRequest { TTL = 5 }, headers, deadline, + CancellationToken.None); + + Assert.Equal(400, result.ID); + mockLeaseClient.Verify(x => x.LeaseGrantAsync(It.IsAny(), headers, deadline, + It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseRevoke (sync) + // --------------------------------------------------------------------- + + [Fact] + public void LeaseRevoke_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseRevoke(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaseRevokeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + client.LeaseRevoke(new LeaseRevokeRequest { ID = 100 }); + + mockLeaseClient.Verify(x => x.LeaseRevoke(It.Is(r => r.ID == 100), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void LeaseRevoke_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseRevoke(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LeaseRevokeResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + client.LeaseRevoke(new LeaseRevokeRequest { ID = 200 }, headers, deadline, CancellationToken.None); + + mockLeaseClient.Verify(x => x.LeaseRevoke(It.Is(r => r.ID == 200), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseRevokeAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseRevokeAsync_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseRevokeAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseRevokeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + await client.LeaseRevokeAsync(new LeaseRevokeRequest { ID = 300 }); + + mockLeaseClient.Verify(x => x.LeaseRevokeAsync(It.Is(r => r.ID == 300), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LeaseRevokeAsync_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseRevokeAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseRevokeResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + await client.LeaseRevokeAsync(new LeaseRevokeRequest { ID = 400 }, headers, deadline, + CancellationToken.None); + + mockLeaseClient.Verify(x => x.LeaseRevokeAsync(It.Is(r => r.ID == 400), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseTimeToLive (sync) + // --------------------------------------------------------------------- + + [Fact] + public void LeaseTimeToLive_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseTimeToLive(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(new LeaseTimeToLiveResponse { ID = 100, TTL = 7 }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var result = client.LeaseTimeToLive(new LeaseTimeToLiveRequest { ID = 100 }); + + Assert.Equal(100, result.ID); + Assert.Equal(7, result.TTL); + mockLeaseClient.Verify(x => x.LeaseTimeToLive(It.Is(r => r.ID == 100), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void LeaseTimeToLive_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseTimeToLive(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(new LeaseTimeToLiveResponse { ID = 200, TTL = 9 }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var result = client.LeaseTimeToLive(new LeaseTimeToLiveRequest { ID = 200, Keys = true }, headers, + deadline, CancellationToken.None); + + Assert.Equal(200, result.ID); + Assert.Equal(9, result.TTL); + mockLeaseClient.Verify(x => x.LeaseTimeToLive(It.Is(r => r.ID == 200), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseTimeToLiveAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseTimeToLiveAsync_WithRequestOnly_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseTimeToLiveAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseTimeToLiveResponse { ID = 300, TTL = 4 })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var result = await client.LeaseTimeToLiveAsync(new LeaseTimeToLiveRequest { ID = 300 }); + + Assert.Equal(300, result.ID); + Assert.Equal(4, result.TTL); + mockLeaseClient.Verify(x => x.LeaseTimeToLiveAsync(It.Is(r => r.ID == 300), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LeaseTimeToLiveAsync_WithAllParameters_ShouldCallGrpcClient() + { + var mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseTimeToLiveAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LeaseTimeToLiveResponse { ID = 400, TTL = 6 })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var result = await client.LeaseTimeToLiveAsync(new LeaseTimeToLiveRequest { ID = 400, Keys = true }, + headers, deadline, CancellationToken.None); + + Assert.Equal(400, result.ID); + Assert.Equal(6, result.TTL); + mockLeaseClient.Verify(x => x.LeaseTimeToLiveAsync(It.Is(r => r.ID == 400), + headers, deadline, It.IsAny()), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/LockClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/LockClientCoverageTests.cs new file mode 100644 index 0000000..1484ea2 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/LockClientCoverageTests.cs @@ -0,0 +1,393 @@ +using dotnet_etcd.Tests.Infrastructure; +using Google.Protobuf; +using Grpc.Core; +using Moq; +using V3Lockpb; +using Lock = V3Lockpb.Lock; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the Lock client unary methods. +/// Every public UNARY Lock method + overload (string and request based) is exercised +/// in both sync and async form. +/// +[Trait("Category", "Unit")] +public class LockClientCoverageTests +{ + // --------------------------------------------------------------------- + // Lock (sync) - string overload + // --------------------------------------------------------------------- + + [Fact] + public void Lock_WithName_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var name = "my-lock"; + var expectedKey = ByteString.CopyFromUtf8("my-lock-key"); + mockLockClient + .Setup(x => x.Lock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LockResponse { Key = expectedKey }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var result = client.Lock(name); + + Assert.Equal(expectedKey, result.Key); + mockLockClient.Verify(x => x.Lock( + It.Is(r => r.Name.Equals(ByteString.CopyFromUtf8(name))), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Lock_WithNameAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var name = "my-lock"; + mockLockClient + .Setup(x => x.Lock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + client.Lock(name, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.Lock( + It.Is(r => r.Name.Equals(ByteString.CopyFromUtf8(name))), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Lock (sync) - request overload + // --------------------------------------------------------------------- + + [Fact] + public void Lock_WithRequest_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var expectedKey = ByteString.CopyFromUtf8("req-lock-key"); + mockLockClient + .Setup(x => x.Lock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LockResponse { Key = expectedKey }); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var request = new LockRequest { Name = ByteString.CopyFromUtf8("req-lock"), Lease = 55 }; + var result = client.Lock(request); + + Assert.Equal(expectedKey, result.Key); + mockLockClient.Verify(x => x.Lock( + It.Is(r => r.Lease == 55), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Lock_WithRequestAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + mockLockClient + .Setup(x => x.Lock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new LockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var request = new LockRequest { Name = ByteString.CopyFromUtf8("req-lock"), Lease = 66 }; + client.Lock(request, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.Lock( + It.Is(r => r.Lease == 66), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LockAsync - string overload + // --------------------------------------------------------------------- + + [Fact] + public async Task LockAsync_WithName_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var name = "my-lock"; + var expectedKey = ByteString.CopyFromUtf8("my-lock-key"); + mockLockClient + .Setup(x => x.LockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LockResponse { Key = expectedKey })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var result = await client.LockAsync(name); + + Assert.Equal(expectedKey, result.Key); + mockLockClient.Verify(x => x.LockAsync( + It.Is(r => r.Name.Equals(ByteString.CopyFromUtf8(name))), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LockAsync_WithNameAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var name = "my-lock"; + mockLockClient + .Setup(x => x.LockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + await client.LockAsync(name, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.LockAsync( + It.Is(r => r.Name.Equals(ByteString.CopyFromUtf8(name))), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LockAsync - request overload + // --------------------------------------------------------------------- + + [Fact] + public async Task LockAsync_WithRequest_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var expectedKey = ByteString.CopyFromUtf8("req-lock-key"); + mockLockClient + .Setup(x => x.LockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LockResponse { Key = expectedKey })); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var request = new LockRequest { Name = ByteString.CopyFromUtf8("req-lock"), Lease = 77 }; + var result = await client.LockAsync(request); + + Assert.Equal(expectedKey, result.Key); + mockLockClient.Verify(x => x.LockAsync( + It.Is(r => r.Lease == 77), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LockAsync_WithRequestAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + mockLockClient + .Setup(x => x.LockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new LockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + var request = new LockRequest { Name = ByteString.CopyFromUtf8("req-lock"), Lease = 88 }; + await client.LockAsync(request, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.LockAsync( + It.Is(r => r.Lease == 88), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Unlock (sync) - string overload + // --------------------------------------------------------------------- + + [Fact] + public void Unlock_WithKey_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = "my-lock-key"; + mockLockClient + .Setup(x => x.Unlock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new UnlockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + client.Unlock(key); + + mockLockClient.Verify(x => x.Unlock( + It.Is(r => r.Key.Equals(ByteString.CopyFromUtf8(key))), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Unlock_WithKeyAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = "my-lock-key"; + mockLockClient + .Setup(x => x.Unlock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new UnlockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + client.Unlock(key, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.Unlock( + It.Is(r => r.Key.Equals(ByteString.CopyFromUtf8(key))), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // Unlock (sync) - request overload + // --------------------------------------------------------------------- + + [Fact] + public void Unlock_WithRequest_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = ByteString.CopyFromUtf8("req-unlock-key"); + mockLockClient + .Setup(x => x.Unlock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new UnlockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + client.Unlock(new UnlockRequest { Key = key }); + + mockLockClient.Verify(x => x.Unlock( + It.Is(r => r.Key.Equals(key)), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void Unlock_WithRequestAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = ByteString.CopyFromUtf8("req-unlock-key"); + mockLockClient + .Setup(x => x.Unlock(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new UnlockResponse()); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + client.Unlock(new UnlockRequest { Key = key }, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.Unlock( + It.Is(r => r.Key.Equals(key)), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // UnlockAsync - string overload + // --------------------------------------------------------------------- + + [Fact] + public async Task UnlockAsync_WithKey_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = "my-lock-key"; + mockLockClient + .Setup(x => x.UnlockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new UnlockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + await client.UnlockAsync(key); + + mockLockClient.Verify(x => x.UnlockAsync( + It.Is(r => r.Key.Equals(ByteString.CopyFromUtf8(key))), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UnlockAsync_WithKeyAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = "my-lock-key"; + mockLockClient + .Setup(x => x.UnlockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new UnlockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + await client.UnlockAsync(key, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.UnlockAsync( + It.Is(r => r.Key.Equals(ByteString.CopyFromUtf8(key))), + headers, deadline, It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // UnlockAsync - request overload + // --------------------------------------------------------------------- + + [Fact] + public async Task UnlockAsync_WithRequest_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = ByteString.CopyFromUtf8("req-unlock-key"); + mockLockClient + .Setup(x => x.UnlockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new UnlockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + await client.UnlockAsync(new UnlockRequest { Key = key }); + + mockLockClient.Verify(x => x.UnlockAsync( + It.Is(r => r.Key.Equals(key)), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UnlockAsync_WithRequestAndAllParameters_ShouldCallGrpcClient() + { + var mockLockClient = new Mock(); + var key = ByteString.CopyFromUtf8("req-unlock-key"); + mockLockClient + .Setup(x => x.UnlockAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new UnlockResponse())); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLockClient.Object, "_lockClient"); + + var headers = new Metadata { { "key", "value" } }; + var deadline = DateTime.UtcNow.AddSeconds(10); + await client.UnlockAsync(new UnlockRequest { Key = key }, headers, deadline, CancellationToken.None); + + mockLockClient.Verify(x => x.UnlockAsync( + It.Is(r => r.Key.Equals(key)), + headers, deadline, It.IsAny()), Times.Once); + } +} diff --git a/dotnet-etcd.Tests/Unit/MaintenanceClientCoverageTests.cs b/dotnet-etcd.Tests/Unit/MaintenanceClientCoverageTests.cs new file mode 100644 index 0000000..4c3ebad --- /dev/null +++ b/dotnet-etcd.Tests/Unit/MaintenanceClientCoverageTests.cs @@ -0,0 +1,281 @@ +using dotnet_etcd.Tests.Infrastructure; +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +[Trait("Category", "Unit")] +public class MaintenanceClientCoverageTests +{ + private const string MaintenanceClientField = "_maintenanceClient"; + + private static EtcdClient CreateClientWith(Mock mock) + { + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, MaintenanceClientField); + return client; + } + + // ----- Alarm ----- + + [Fact] + public void Alarm_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Alarm( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new AlarmResponse()); + + var client = CreateClientWith(mock); + var request = new AlarmRequest + { + Action = AlarmRequest.Types.AlarmAction.Get, + Alarm = AlarmType.Nospace + }; + + var result = client.Alarm(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.Alarm( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task AlarmAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.AlarmAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new AlarmResponse())); + + var client = CreateClientWith(mock); + var request = new AlarmRequest + { + Action = AlarmRequest.Types.AlarmAction.Get, + Alarm = AlarmType.Nospace + }; + + var result = await client.AlarmAsync(request, new Metadata(), DateTime.UtcNow.AddSeconds(10), + CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.AlarmAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- Status ----- + + [Fact] + public void Status_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Status( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new StatusResponse { Version = "3.5.0" }); + + var client = CreateClientWith(mock); + + var result = client.Status(new StatusRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal("3.5.0", result.Version); + mock.Verify(x => x.Status( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task StatusAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.StatusAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new StatusResponse { Version = "3.5.0" })); + + var client = CreateClientWith(mock); + + var result = await client.StatusAsync(new StatusRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal("3.5.0", result.Version); + mock.Verify(x => x.StatusAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- Defragment ----- + + [Fact] + public void Defragment_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Defragment( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new DefragmentResponse()); + + var client = CreateClientWith(mock); + + var result = client.Defragment(new DefragmentRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.Defragment( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task DefragmentAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.DefragmentAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new DefragmentResponse())); + + var client = CreateClientWith(mock); + + var result = await client.DefragmentAsync(new DefragmentRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.DefragmentAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- Hash ----- + + [Fact] + public void Hash_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.Hash( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new HashResponse { Hash = 999 }); + + var client = CreateClientWith(mock); + + var result = client.Hash(new HashRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal(999u, result.Hash); + mock.Verify(x => x.Hash( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task HashAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.HashAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new HashResponse { Hash = 999 })); + + var client = CreateClientWith(mock); + + var result = await client.HashAsync(new HashRequest(), new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal(999u, result.Hash); + mock.Verify(x => x.HashAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- HashKV ----- + + [Fact] + public void HashKV_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.HashKV( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new HashKVResponse { Hash = 4242 }); + + var client = CreateClientWith(mock); + + var result = client.HashKV(new HashKVRequest { Revision = 10 }, new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal(4242u, result.Hash); + mock.Verify(x => x.HashKV( + It.Is(r => r.Revision == 10), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task HashKVAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.HashKVAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new HashKVResponse { Hash = 4242 })); + + var client = CreateClientWith(mock); + + var result = await client.HashKVAsync(new HashKVRequest { Revision = 10 }, new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.Equal(4242u, result.Hash); + mock.Verify(x => x.HashKVAsync( + It.Is(r => r.Revision == 10), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // ----- MoveLeader ----- + + [Fact] + public void MoveLeader_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MoveLeader( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(new MoveLeaderResponse()); + + var client = CreateClientWith(mock); + + var result = client.MoveLeader(new MoveLeaderRequest { TargetID = 8 }, new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MoveLeader( + It.Is(r => r.TargetID == 8), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task MoveLeaderAsync_ShouldCallGrpcClient() + { + var mock = new Mock(); + mock.Setup(x => x.MoveLeaderAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(TestHelper.CreateAsyncUnaryCall(new MoveLeaderResponse())); + + var client = CreateClientWith(mock); + + var result = await client.MoveLeaderAsync(new MoveLeaderRequest { TargetID = 8 }, new Metadata(), + DateTime.UtcNow.AddSeconds(10), CancellationToken.None); + + Assert.NotNull(result); + mock.Verify(x => x.MoveLeaderAsync( + It.Is(r => r.TargetID == 8), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } +} From cb39457a2962687158ba9d9e5db0a1da8f4841e4 Mon Sep 17 00:00:00 2001 From: Shubham Ranjan Date: Mon, 29 Jun 2026 00:27:43 +0530 Subject: [PATCH 3/5] ci: include integration tests in coverage report Collect coverage from the integration test run too; reportgenerator already globs ./coverage/** and merges the cobertura files, so the badge/report now reflects combined unit + integration coverage instead of unit-only. --- .github/workflows/dotnet-build-test-coverage.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet-build-test-coverage.yml b/.github/workflows/dotnet-build-test-coverage.yml index 01de76c..01baf21 100644 --- a/.github/workflows/dotnet-build-test-coverage.yml +++ b/.github/workflows/dotnet-build-test-coverage.yml @@ -47,8 +47,10 @@ jobs: - name: Run Unit Tests with Coverage run: dotnet test dotnet-etcd.Tests/dotnet-etcd.Tests.csproj --filter "Category=Unit" --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage - - name: Run Integration Tests - run: dotnet test dotnet-etcd.Tests/dotnet-etcd.Tests.csproj --filter "Category=Integration" --no-build --verbosity normal + - name: Run Integration Tests with Coverage + # Collect coverage here too; reportgenerator globs ./coverage/** and merges both + # cobertura files, so the report reflects combined unit + integration coverage. + run: dotnet test dotnet-etcd.Tests/dotnet-etcd.Tests.csproj --filter "Category=Integration" --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Generate Coverage Report run: | From 755897f01413d3233e625ea2259f9462339fef7a Mon Sep 17 00:00:00 2001 From: Shubham Ranjan Date: Wed, 1 Jul 2026 00:10:07 +0530 Subject: [PATCH 4/5] fix(watch): deliver events in array Watch/WatchAsync overloads; add streaming coverage Watch(WatchRequest[], Action[]) and the async variant captured the for-loop variable inside the event-conversion closure, so when an event later arrived the wrapper indexed methods[requests.Length] -> IndexOutOfRangeException on a background task, silently dropping every event. Capture a per-iteration local index. Adds a controllable FakeDuplexStreamingCall plus unit tests for WatchManager, the EtcdClient watch wrappers (incl. delivery tests that fail without the fix), duplex LeaseKeepAlive, server-streaming Observe/Snapshot, and constructor validation. Combined line coverage 46.7% -> 93.8%. --- .../EtcdClientConstructorCoverageTests.cs | 67 +++ .../Unit/EtcdClientWatchCoverageTests.cs | 437 ++++++++++++++++++ .../Unit/LeaseKeepAliveCoverageTests.cs | 213 +++++++++ .../Unit/Mocks/FakeDuplexStreamingCall.cs | 116 +++++ .../Unit/StreamingServerCoverageTests.cs | 183 ++++++++ .../Unit/WatchManagerCoverageTests.cs | 417 +++++++++++++++++ dotnet-etcd/watchclient/watchClient.cs | 14 +- 7 files changed, 1445 insertions(+), 2 deletions(-) create mode 100644 dotnet-etcd.Tests/Unit/EtcdClientConstructorCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/EtcdClientWatchCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/LeaseKeepAliveCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/Mocks/FakeDuplexStreamingCall.cs create mode 100644 dotnet-etcd.Tests/Unit/StreamingServerCoverageTests.cs create mode 100644 dotnet-etcd.Tests/Unit/WatchManagerCoverageTests.cs diff --git a/dotnet-etcd.Tests/Unit/EtcdClientConstructorCoverageTests.cs b/dotnet-etcd.Tests/Unit/EtcdClientConstructorCoverageTests.cs new file mode 100644 index 0000000..d56dfb6 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/EtcdClientConstructorCoverageTests.cs @@ -0,0 +1,67 @@ +using System.Net.Security; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Covers EtcdClient constructor argument validation and the secondary constructor +/// overloads (SSL options, credentials) that are otherwise only hit by integration tests. +/// +[Trait("Category", "Unit")] +public class EtcdClientConstructorCoverageTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_WithNullOrEmptyConnectionString_Throws(string connectionString) + { + Assert.Throws(() => new EtcdClient(connectionString)); + } + + [Fact] + public void Constructor_WithCallInvoker_Null_Throws() + { + Assert.Throws(() => new EtcdClient((CallInvoker)null)); + } + + [Theory] + [InlineData(null, "pass")] + [InlineData("", "pass")] + [InlineData(" ", "pass")] + [InlineData("user", null)] + [InlineData("user", "")] + [InlineData("user", " ")] + public void Constructor_WithCredentials_InvalidUserOrPassword_Throws(string username, string password) + { + Assert.Throws(() => + new EtcdClient("127.0.0.1:2379", username, password)); + } + + [Fact] + public void Constructor_WithCredentials_Valid_DoesNotThrow() + { + // Lazy auth: no etcd contact at construction time. + using var client = new EtcdClient("127.0.0.1:2379", "user", "pass", + tokenCacheDuration: TimeSpan.FromMinutes(2)); + Assert.NotNull(client); + } + + [Fact] + public void Constructor_WithSslOptions_DoesNotThrow() + { + // Exercises the configureSslOptions constructor overload (SSL credentials path). + using var client = new EtcdClient( + "https://127.0.0.1:2379", + configureSslOptions: (SslClientAuthenticationOptions _) => { }); + Assert.NotNull(client); + } + + [Fact] + public void Constructor_WithConnectionString_DoesNotThrow() + { + using var client = new EtcdClient("127.0.0.1:2379"); + Assert.NotNull(client); + } +} diff --git a/dotnet-etcd.Tests/Unit/EtcdClientWatchCoverageTests.cs b/dotnet-etcd.Tests/Unit/EtcdClientWatchCoverageTests.cs new file mode 100644 index 0000000..3c24163 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/EtcdClientWatchCoverageTests.cs @@ -0,0 +1,437 @@ +using System.Diagnostics; +using dotnet_etcd.Tests.Unit.Mocks; +using Etcdserverpb; +using Google.Protobuf; +using Mvccpb; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the EtcdClient watch wrappers (watchClient.cs). +/// Each test wires a real backed by a +/// into an EtcdClient via the +/// test constructor, then drives responses to exercise the conversion/fan-out lambdas. +/// +[Trait("Category", "Unit")] +public class EtcdClientWatchCoverageTests +{ + // First client-generated watch id within a fresh manager. + private const long FirstWatchId = 2; + + private static EtcdClient CreateClient(out FakeDuplexStreamingCall fake) + { + var localFake = new FakeDuplexStreamingCall(); + fake = localFake; + var connection = MockConnection.Create().Object; + var manager = new WatchManager((_, _, _) => localFake); + return new EtcdClient(connection, manager); + } + + private static WatchRequest KeyRequest(string key) => + new() { CreateRequest = new WatchCreateRequest { Key = ByteString.CopyFromUtf8(key) } }; + + private static WatchResponse EventResponse(long watchId, string key = "k", string value = "v") + { + var response = new WatchResponse { WatchId = watchId }; + response.Events.Add(new Event + { + Type = Event.Types.EventType.Put, + Kv = new KeyValue { Key = ByteString.CopyFromUtf8(key), Value = ByteString.CopyFromUtf8(value) } + }); + return response; + } + + private static bool WaitFor(Func condition, int timeoutMs = 2000) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (condition()) + { + return true; + } + + Thread.Sleep(10); + } + + return condition(); + } + + // --------------------------------------------------------------------- + // Single request wrappers + // --------------------------------------------------------------------- + + [Fact] + public async Task WatchAsync_Request_WatchResponse_DeliversResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + long id = await client.WatchAsync(KeyRequest("a"), r => responses.Add(r)); + Assert.Equal(FirstWatchId, id); + fake.Enqueue(EventResponse(id)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public async Task WatchAsync_Request_WatchResponseArray_DeliversToAll() + { + var client = CreateClient(out var fake); + int a = 0, b = 0; + long id = await client.WatchAsync(KeyRequest("a"), + new Action[] { _ => Interlocked.Increment(ref a), _ => Interlocked.Increment(ref b) }); + fake.Enqueue(EventResponse(id)); + Assert.True(WaitFor(() => a > 0 && b > 0)); + } + + [Fact] + public async Task WatchAsync_Request_WatchEventArray_DeliversConvertedEvents() + { + var client = CreateClient(out var fake); + var events = new List(); + await client.WatchAsync(KeyRequest("a"), (WatchEvent[] e) => events.AddRange(e)); + fake.Enqueue(EventResponse(FirstWatchId, "a", "1")); + Assert.True(WaitFor(() => events.Count > 0)); + Assert.Equal("a", events[0].Key); + } + + [Fact] + public async Task WatchAsync_Request_WatchEventArrayMethods_DeliversConvertedEvents() + { + var client = CreateClient(out var fake); + int a = 0, b = 0; + await client.WatchAsync(KeyRequest("a"), new Action[] + { + _ => Interlocked.Increment(ref a), _ => Interlocked.Increment(ref b) + }); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => a > 0 && b > 0)); + } + + [Fact] + public void Watch_Request_WatchResponse_DeliversResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + client.Watch(KeyRequest("a"), (WatchResponse r) => responses.Add(r)); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public void Watch_Request_WatchResponseArray_DeliversResponse() + { + var client = CreateClient(out var fake); + int a = 0; + client.Watch(KeyRequest("a"), new Action[] { _ => Interlocked.Increment(ref a) }); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => a > 0)); + } + + // --------------------------------------------------------------------- + // Request array wrappers + // --------------------------------------------------------------------- + + [Fact] + public async Task WatchAsync_RequestArray_WatchEventArrayMethods_RegistersAllWatches() + { + // Registration-only check; delivery to the matching handler is covered by + // WatchAsync_RequestArray_WatchEventArrayMethods_DeliversToMatchingHandler. + var client = CreateClient(out var fake); + long[] ids = await client.WatchAsync( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] { _ => { }, _ => { } }); + + Assert.Equal(2, ids.Length); + Assert.True(WaitFor(() => fake.Requests.Written.Count(w => w.CreateRequest != null) == 2)); + } + + [Fact] + public async Task WatchAsync_RequestArray_WatchEventArrayMethods_DeliversToMatchingHandler() + { + var client = CreateClient(out var fake); + int a = 0, b = 0; + long[] ids = await client.WatchAsync( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] + { + evts => { if (evts.Length > 0) Interlocked.Increment(ref a); }, + evts => { if (evts.Length > 0) Interlocked.Increment(ref b); }, + }); + + Assert.Equal(2, ids.Length); + fake.Enqueue(EventResponse(ids[0], "a", "1")); + fake.Enqueue(EventResponse(ids[1], "b", "2")); + + Assert.True(WaitFor(() => a >= 1 && b >= 1), $"Both handlers should fire; a={a}, b={b}"); + } + + [Fact] + public void Watch_RequestArray_WatchEventArrayMethods_DeliversToMatchingHandler() + { + var client = CreateClient(out var fake); + int a = 0, b = 0; + client.Watch( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] + { + evts => { if (evts.Length > 0) Interlocked.Increment(ref a); }, + evts => { if (evts.Length > 0) Interlocked.Increment(ref b); }, + }); + + fake.Enqueue(EventResponse(FirstWatchId, "a", "1")); + fake.Enqueue(EventResponse(FirstWatchId + 1, "b", "2")); + + Assert.True(WaitFor(() => a >= 1 && b >= 1), $"Both handlers should fire; a={a}, b={b}"); + } + + [Fact] + public async Task WatchAsync_RequestArray_SingleWatchEventArrayMethod_Match() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = await client.WatchAsync( + new[] { KeyRequest("a"), KeyRequest("b") }, + (WatchEvent[] _) => Interlocked.Increment(ref count)); + + Assert.Equal(2, ids.Length); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public async Task WatchAsync_RequestArray_WatchResponseSingleMethod() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = await client.WatchAsync( + new[] { KeyRequest("a"), KeyRequest("b") }, + (WatchResponse _) => Interlocked.Increment(ref count)); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public async Task WatchAsync_RequestArray_WatchResponseMethods() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = await client.WatchAsync( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] { _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) }); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public void Watch_RequestArray_WatchResponseMethods() + { + var client = CreateClient(out var fake); + int count = 0; + client.Watch( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] { _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) }); + fake.Enqueue(EventResponse(FirstWatchId)); + fake.Enqueue(EventResponse(FirstWatchId + 1)); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public void Watch_RequestArray_WatchEventArrayMethods_RegistersAllWatches() + { + // See note above: this wrapper captures the loop variable, so we assert registration only. + var client = CreateClient(out var fake); + client.Watch( + new[] { KeyRequest("a"), KeyRequest("b") }, + new Action[] { _ => { }, _ => { } }); + Assert.True(WaitFor(() => fake.Requests.Written.Count(w => w.CreateRequest != null) == 2)); + } + + // --------------------------------------------------------------------- + // Mismatched length argument validation + // --------------------------------------------------------------------- + + [Fact] + public async Task WatchAsync_RequestArray_LengthMismatch_Throws() + { + var client = CreateClient(out _); + await Assert.ThrowsAsync(() => client.WatchAsync( + new[] { KeyRequest("a") }, + new Action[] { _ => { }, _ => { } })); + } + + [Fact] + public void Watch_RequestArray_LengthMismatch_Throws() + { + var client = CreateClient(out _); + Assert.Throws(() => client.Watch( + new[] { KeyRequest("a") }, + new Action[] { _ => { }, _ => { } })); + } + + // --------------------------------------------------------------------- + // String key wrappers + // --------------------------------------------------------------------- + + [Fact] + public void Watch_StringKey_WatchResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + client.Watch("k", (WatchResponse r) => responses.Add(r)); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public void Watch_StringKey_WatchEventArray_RegistersWatch() + { + // Routes through Watch(WatchRequest[], Action[]) which captures the loop variable; + // assert registration only. + var client = CreateClient(out var fake); + client.Watch("k", (WatchEvent[] _) => { }); + Assert.True(WaitFor(() => fake.Requests.Written.Any(w => w.CreateRequest != null))); + } + + [Fact] + public async Task WatchAsync_StringKey_WatchResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + await client.WatchAsync("k", (WatchResponse r) => responses.Add(r)); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public async Task WatchAsync_StringKey_WatchEventArray_RegistersWatch() + { + // Routes through WatchAsync(WatchRequest[], Action[]) which captures the loop + // variable; assert registration only. + var client = CreateClient(out var fake); + await client.WatchAsync("k", (WatchEvent[] _) => { }); + Assert.True(WaitFor(() => fake.Requests.Written.Any(w => w.CreateRequest != null))); + } + + // --------------------------------------------------------------------- + // WatchRange wrappers + // --------------------------------------------------------------------- + + [Fact] + public void WatchRange_Path_WatchResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + long id = client.WatchRange("p", (WatchResponse r) => responses.Add(r)); + Assert.Equal(FirstWatchId, id); + fake.Enqueue(EventResponse(id)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public void WatchRange_Path_WatchResponseArray() + { + var client = CreateClient(out var fake); + int count = 0; + client.WatchRange("p", new Action[] + { + _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) + }); + fake.Enqueue(EventResponse(FirstWatchId)); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public void WatchRange_PathArray_WatchResponse() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = client.WatchRange(new[] { "p", "q" }, (WatchResponse _) => Interlocked.Increment(ref count)); + Assert.Equal(2, ids.Length); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public void WatchRange_PathArray_WatchResponseMethods() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = client.WatchRange(new[] { "p", "q" }, new Action[] + { + _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) + }); + Assert.Equal(2, ids.Length); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public void WatchRange_PathArray_LengthMismatch_Throws() + { + var client = CreateClient(out _); + Assert.Throws(() => client.WatchRange( + new[] { "p" }, new Action[] { _ => { }, _ => { } })); + } + + [Fact] + public async Task WatchRangeAsync_Path_WatchResponse() + { + var client = CreateClient(out var fake); + var responses = new List(); + long id = await client.WatchRangeAsync("p", (WatchResponse r) => responses.Add(r)); + fake.Enqueue(EventResponse(id)); + Assert.True(WaitFor(() => responses.Count > 0)); + } + + [Fact] + public async Task WatchRangeAsync_Path_WatchResponseArray() + { + var client = CreateClient(out var fake); + int count = 0; + long id = await client.WatchRangeAsync("p", new Action[] + { + _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) + }); + fake.Enqueue(EventResponse(id)); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public async Task WatchRangeAsync_PathArray_WatchResponse() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = await client.WatchRangeAsync(new[] { "p", "q" }, + (WatchResponse _) => Interlocked.Increment(ref count)); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public async Task WatchRangeAsync_PathArray_WatchResponseMethods() + { + var client = CreateClient(out var fake); + int count = 0; + long[] ids = await client.WatchRangeAsync(new[] { "p", "q" }, new Action[] + { + _ => Interlocked.Increment(ref count), _ => Interlocked.Increment(ref count) + }); + fake.Enqueue(EventResponse(ids[0])); + fake.Enqueue(EventResponse(ids[1])); + Assert.True(WaitFor(() => count >= 2)); + } + + [Fact] + public async Task WatchRangeAsync_PathArray_LengthMismatch_Throws() + { + var client = CreateClient(out _); + await Assert.ThrowsAsync(() => client.WatchRangeAsync( + new[] { "p" }, new Action[] { _ => { }, _ => { } })); + } +} diff --git a/dotnet-etcd.Tests/Unit/LeaseKeepAliveCoverageTests.cs b/dotnet-etcd.Tests/Unit/LeaseKeepAliveCoverageTests.cs new file mode 100644 index 0000000..5cfbc32 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/LeaseKeepAliveCoverageTests.cs @@ -0,0 +1,213 @@ +using dotnet_etcd.Tests.Infrastructure; +using dotnet_etcd.Tests.Unit.Mocks; +using Etcdserverpb; +using Grpc.Core; +using Moq; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the duplex-streaming LeaseKeepAlive overloads. +/// A real is built over a recording +/// request writer and a finite response reader, mirroring how TestHelper builds AsyncUnaryCall. +/// +[Trait("Category", "Unit")] +public class LeaseKeepAliveCoverageTests +{ + private static AsyncDuplexStreamingCall BuildCall( + RecordingClientStreamWriter writer, + IEnumerable responses) => + new( + writer, + new TestAsyncStreamReader(responses), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + + private static EtcdClient CreateClient( + AsyncDuplexStreamingCall call, + out Mock mockLeaseClient) + { + mockLeaseClient = new Mock(); + mockLeaseClient + .Setup(x => x.LeaseKeepAlive(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(call); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mockLeaseClient.Object, "_leaseClient"); + return client; + } + + private static LeaseKeepAliveResponse Response(long id, long ttl) => + new() { ID = id, TTL = ttl }; + + // --------------------------------------------------------------------- + // LeaseKeepAlive(request, method, ct) + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_Request_SingleMethod_InvokesMethodAndWritesRequest() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, new[] { Response(1, 5) }); + var client = CreateClient(call, out var mock); + + var received = new List(); + await client.LeaseKeepAlive(new LeaseKeepAliveRequest { ID = 1 }, received.Add, CancellationToken.None); + + Assert.Single(received); + Assert.Equal(1, received[0].ID); + Assert.Contains(writer.Written, r => r.ID == 1); + Assert.True(writer.IsCompleted); + mock.Verify(x => x.LeaseKeepAlive(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + // --------------------------------------------------------------------- + // LeaseKeepAlive(request, methods[], ct) + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_Request_MultipleMethods_InvokesAll() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, new[] { Response(2, 5) }); + var client = CreateClient(call, out _); + + int a = 0, b = 0; + await client.LeaseKeepAlive(new LeaseKeepAliveRequest { ID = 2 }, + new Action[] { _ => a++, _ => b++ }, CancellationToken.None); + + Assert.Equal(1, a); + Assert.Equal(1, b); + Assert.True(writer.IsCompleted); + } + + // --------------------------------------------------------------------- + // LeaseKeepAlive(requests[], method, ct) + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_RequestArray_SingleMethod_WritesAllRequests() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, new[] { Response(3, 5) }); + var client = CreateClient(call, out _); + + var received = new List(); + await client.LeaseKeepAlive( + new[] { new LeaseKeepAliveRequest { ID = 3 }, new LeaseKeepAliveRequest { ID = 4 } }, + received.Add, CancellationToken.None); + + Assert.Single(received); + Assert.Equal(2, writer.Written.Count); + Assert.True(writer.IsCompleted); + } + + // --------------------------------------------------------------------- + // LeaseKeepAlive(requests[], methods[], ct, headers, deadline) + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_RequestArray_MultipleMethods_WritesAllAndInvokesAll() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, new[] { Response(5, 5) }); + var client = CreateClient(call, out _); + + int a = 0, b = 0; + await client.LeaseKeepAlive( + new[] { new LeaseKeepAliveRequest { ID = 5 }, new LeaseKeepAliveRequest { ID = 6 } }, + new Action[] { _ => a++, _ => b++ }, + CancellationToken.None); + + Assert.Equal(1, a); + Assert.Equal(1, b); + Assert.Equal(2, writer.Written.Count); + } + + // --------------------------------------------------------------------- + // LeaseKeepAlive(leaseId, ct) + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_LeaseId_ReturnsWhenLeaseExpired() + { + var writer = new RecordingClientStreamWriter(); + // TTL == 0 means the lease is expired, so the loop completes and returns. + var call = BuildCall(writer, new[] { Response(10, 0) }); + var client = CreateClient(call, out _); + + await client.LeaseKeepAlive(10, CancellationToken.None); + + Assert.Contains(writer.Written, r => r.ID == 10); + Assert.True(writer.IsCompleted); + } + + [Fact] + public async Task LeaseKeepAlive_LeaseId_StreamEnds_ThrowsEndOfStream() + { + var writer = new RecordingClientStreamWriter(); + // Empty response stream -> MoveNext returns false immediately -> EndOfStreamException. + var call = BuildCall(writer, Array.Empty()); + var client = CreateClient(call, out _); + + await Assert.ThrowsAsync(() => client.LeaseKeepAlive(11, CancellationToken.None)); + Assert.True(writer.IsCompleted); + } + + // --------------------------------------------------------------------- + // LeaseKeepAlive(cancellationTokenSource, leaseId, ...) - timer based + // --------------------------------------------------------------------- + + [Fact] + public async Task LeaseKeepAlive_WithCts_ReturnsWhenLeaseExpired() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, new[] { Response(20, 0) }); + var client = CreateClient(call, out _); + + using var cts = new CancellationTokenSource(); + await client.LeaseKeepAlive(cts, 20); + + Assert.Contains(writer.Written, r => r.ID == 20); + Assert.True(cts.IsCancellationRequested); + } + + [Fact] + public async Task LeaseKeepAlive_WithCts_StreamEnds_CompletesGracefully() + { + var writer = new RecordingClientStreamWriter(); + var call = BuildCall(writer, Array.Empty()); + var client = CreateClient(call, out _); + + using var cts = new CancellationTokenSource(); + await client.LeaseKeepAlive(cts, 21); + + Assert.True(writer.IsCompleted); + } + + [Fact] + public async Task LeaseKeepAlive_WithCts_NullSource_Throws() + { + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + await Assert.ThrowsAsync(() => client.LeaseKeepAlive(null!, 1)); + } + + [Fact] + public async Task LeaseKeepAlive_WithCts_NonPositiveKeepAliveTimeout_Throws() + { + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + using var cts = new CancellationTokenSource(); + await Assert.ThrowsAsync(() => client.LeaseKeepAlive(cts, 1, 0)); + } + + [Fact] + public async Task LeaseKeepAlive_WithCts_NonPositiveCommunicationTimeout_Throws() + { + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + using var cts = new CancellationTokenSource(); + await Assert.ThrowsAsync(() => client.LeaseKeepAlive(cts, 1, 1000, 0)); + } +} diff --git a/dotnet-etcd.Tests/Unit/Mocks/FakeDuplexStreamingCall.cs b/dotnet-etcd.Tests/Unit/Mocks/FakeDuplexStreamingCall.cs new file mode 100644 index 0000000..3cfb57b --- /dev/null +++ b/dotnet-etcd.Tests/Unit/Mocks/FakeDuplexStreamingCall.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using dotnet_etcd.interfaces; +using Grpc.Core; + +namespace dotnet_etcd.Tests.Unit.Mocks; + +/// +/// An that records every request written to it so that +/// tests can assert which requests (e.g. WatchCreate / WatchCancel) were sent over the stream. +/// +public class RecordingClientStreamWriter : IClientStreamWriter +{ + private readonly ConcurrentQueue _written = new(); + + /// All requests written to the stream, in order. + public IReadOnlyCollection Written => _written; + + /// True once has been called. + public bool IsCompleted { get; private set; } + + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) + { + _written.Enqueue(message); + return Task.CompletedTask; + } + + public Task WriteAsync(T message, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _written.Enqueue(message); + return Task.CompletedTask; + } + + public Task CompleteAsync() + { + IsCompleted = true; + return Task.CompletedTask; + } +} + +/// +/// An backed by an unbounded channel so that a test can +/// drive a background receive loop by enqueuing responses and completing the stream on demand. +/// Optionally configured to throw a supplied exception from to exercise +/// stream-error handling paths. +/// +public class ChannelStreamReader : IAsyncStreamReader +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + private T _current = default!; + + /// When set, throws this exception instead of reading. + public Exception? ThrowOnMoveNext { get; set; } + + public T Current => _current; + + public async Task MoveNext(CancellationToken cancellationToken) + { + if (ThrowOnMoveNext != null) + { + throw ThrowOnMoveNext; + } + + if (await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false) && + _channel.Reader.TryRead(out T? item)) + { + _current = item; + return true; + } + + return false; + } + + public void Enqueue(T item) => _channel.Writer.TryWrite(item); + + public void Complete() => _channel.Writer.TryComplete(); +} + +/// +/// A controllable fake implementation of . +/// The request stream records written requests; the response stream is driven by a channel so the +/// test can responses and the stream. +/// +public class FakeDuplexStreamingCall : IAsyncDuplexStreamingCall +{ + public RecordingClientStreamWriter Requests { get; } = new(); + public ChannelStreamReader Responses { get; } = new(); + + /// True once has been called. + public bool IsDisposed { get; private set; } + + public IClientStreamWriter RequestStream => Requests; + public IAsyncStreamReader ResponseStream => Responses; + + public Task GetHeadersAsync() => Task.FromResult(new Metadata()); + + public Status GetStatus() => Status.DefaultSuccess; + + public Metadata GetTrailers() => new(); + + public void Dispose() + { + IsDisposed = true; + Responses.Complete(); + GC.SuppressFinalize(this); + } + + /// Pushes a response into the response stream for the receive loop to pick up. + public void Enqueue(TResponse response) => Responses.Enqueue(response); + + /// Ends the response stream so the receive loop completes. + public void Complete() => Responses.Complete(); +} diff --git a/dotnet-etcd.Tests/Unit/StreamingServerCoverageTests.cs b/dotnet-etcd.Tests/Unit/StreamingServerCoverageTests.cs new file mode 100644 index 0000000..8bc6985 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/StreamingServerCoverageTests.cs @@ -0,0 +1,183 @@ +using dotnet_etcd.Tests.Infrastructure; +using dotnet_etcd.Tests.Unit.Mocks; +using Etcdserverpb; +using Grpc.Core; +using Moq; +using V3Electionpb; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the server-streaming wrappers: election Observe / ObserveAsync +/// and maintenance Snapshot. Canned responses are supplied via +/// . +/// +[Trait("Category", "Unit")] +public class StreamingServerCoverageTests +{ + private static EtcdClient CreateElectionClient(IEnumerable responses, + out Mock mock) + { + mock = new Mock(); + mock.Setup(x => x.Observe(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(AsyncStreamingCallFactory.Create(responses)); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_electionClient"); + return client; + } + + private static EtcdClient CreateMaintenanceClient(IEnumerable responses, + out Mock mock) + { + mock = new Mock(); + mock.Setup(x => x.Snapshot(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(AsyncStreamingCallFactory.Create(responses)); + + var client = TestHelper.CreateEtcdClientWithMockCallInvoker(); + TestHelper.SetupMockClientViaConnection(client, mock.Object, "_maintenanceClient"); + return client; + } + + // --------------------------------------------------------------------- + // Observe (server-streaming, returns the call) + // --------------------------------------------------------------------- + + [Fact] + public async Task Observe_Request_ReturnsStreamingCall() + { + var client = CreateElectionClient(new[] { new LeaderResponse(), new LeaderResponse() }, out var mock); + + using var call = client.Observe(new LeaderRequest()); + var collected = new List(); + while (await call.ResponseStream.MoveNext(CancellationToken.None)) + { + collected.Add(call.ResponseStream.Current); + } + + Assert.Equal(2, collected.Count); + mock.Verify(x => x.Observe(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Observe_Name_ReturnsStreamingCall() + { + var client = CreateElectionClient(new[] { new LeaderResponse() }, out _); + + using var call = client.Observe("my-election"); + var collected = new List(); + while (await call.ResponseStream.MoveNext(CancellationToken.None)) + { + collected.Add(call.ResponseStream.Current); + } + + Assert.Single(collected); + } + + [Fact] + public void Observe_NullRequest_Throws() + { + var client = CreateElectionClient(Array.Empty(), out _); + Assert.Throws(() => client.Observe((LeaderRequest)null!)); + } + + [Fact] + public void Observe_NullName_Throws() + { + var client = CreateElectionClient(Array.Empty(), out _); + Assert.Throws(() => client.Observe((string)null!)); + } + + // --------------------------------------------------------------------- + // ObserveAsync (IAsyncEnumerable) + // --------------------------------------------------------------------- + + [Fact] + public async Task ObserveAsync_Request_YieldsAllResponses() + { + var client = CreateElectionClient(new[] { new LeaderResponse(), new LeaderResponse(), new LeaderResponse() }, + out _); + + var collected = new List(); + await foreach (var response in client.ObserveAsync(new LeaderRequest())) + { + collected.Add(response); + } + + Assert.Equal(3, collected.Count); + } + + [Fact] + public async Task ObserveAsync_Name_YieldsAllResponses() + { + var client = CreateElectionClient(new[] { new LeaderResponse() }, out _); + + var collected = new List(); + await foreach (var response in client.ObserveAsync("name")) + { + collected.Add(response); + } + + Assert.Single(collected); + } + + [Fact] + public async Task ObserveAsync_NullRequest_Throws() + { + var client = CreateElectionClient(Array.Empty(), out _); + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in client.ObserveAsync((LeaderRequest)null!)) + { + } + }); + } + + [Fact] + public async Task ObserveAsync_NullName_Throws() + { + var client = CreateElectionClient(Array.Empty(), out _); + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in client.ObserveAsync((string)null!)) + { + } + }); + } + + // --------------------------------------------------------------------- + // Snapshot (server-streaming) + // --------------------------------------------------------------------- + + [Fact] + public async Task Snapshot_SingleMethod_InvokesForEachResponse() + { + var client = CreateMaintenanceClient( + new[] { new SnapshotResponse { RemainingBytes = 2 }, new SnapshotResponse { RemainingBytes = 1 } }, + out var mock); + + var received = new List(); + await client.Snapshot(new SnapshotRequest(), received.Add, CancellationToken.None); + + Assert.Equal(2, received.Count); + mock.Verify(x => x.Snapshot(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Snapshot_MultipleMethods_InvokesAllForEachResponse() + { + var client = CreateMaintenanceClient( + new[] { new SnapshotResponse { RemainingBytes = 1 } }, out _); + + int a = 0, b = 0; + await client.Snapshot(new SnapshotRequest(), + new Action[] { _ => a++, _ => b++ }, CancellationToken.None); + + Assert.Equal(1, a); + Assert.Equal(1, b); + } +} diff --git a/dotnet-etcd.Tests/Unit/WatchManagerCoverageTests.cs b/dotnet-etcd.Tests/Unit/WatchManagerCoverageTests.cs new file mode 100644 index 0000000..956e102 --- /dev/null +++ b/dotnet-etcd.Tests/Unit/WatchManagerCoverageTests.cs @@ -0,0 +1,417 @@ +using System.Diagnostics; +using dotnet_etcd.interfaces; +using dotnet_etcd.Tests.Unit.Mocks; +using Etcdserverpb; +using Google.Protobuf; +using Grpc.Core; +using Mvccpb; + +namespace dotnet_etcd.Tests.Unit; + +/// +/// Coverage-focused unit tests for the streaming watch stack: +/// , and the EtcdClient watch wrappers. +/// Everything is driven through a so no +/// live etcd server is required. The fake's response stream is driven by enqueuing responses; the +/// background receive loop then invokes user callbacks which the tests await with a bounded timeout. +/// +[Trait("Category", "Unit")] +public class WatchManagerCoverageTests +{ + // The first client-generated watch id is 2 because _nextWatchId starts at 1 and is pre-incremented. + private const long FirstWatchId = 2; + + private static WatchRequest KeyRequest(string key) => + new() { CreateRequest = new WatchCreateRequest { Key = ByteString.CopyFromUtf8(key) } }; + + private static WatchResponse EventResponse(long watchId, string key, string value, Event.Types.EventType type = + Event.Types.EventType.Put) + { + var response = new WatchResponse { WatchId = watchId }; + response.Events.Add(new Event + { + Type = type, + Kv = new KeyValue { Key = ByteString.CopyFromUtf8(key), Value = ByteString.CopyFromUtf8(value) } + }); + return response; + } + + private static bool WaitFor(Func condition, int timeoutMs = 2000) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (condition()) + { + return true; + } + + Thread.Sleep(10); + } + + return condition(); + } + + private static WatchManager CreateManager(out FakeDuplexStreamingCall fake) + { + var localFake = new FakeDuplexStreamingCall(); + fake = localFake; + return new WatchManager((_, _, _) => localFake); + } + + // --------------------------------------------------------------------- + // Constructor + // --------------------------------------------------------------------- + + [Fact] + public void Constructor_WithNullFactory_ThrowsArgumentNullException() + { + Assert.Throws(() => new WatchManager(null!)); + } + + // --------------------------------------------------------------------- + // WatchAsync(WatchRequest, Action) - core path + // --------------------------------------------------------------------- + + [Fact] + public async Task WatchAsync_FiresCallback_AndRecordsCreateRequest() + { + var manager = CreateManager(out var fake); + var received = new List(); + using var fired = new ManualResetEventSlim(); + + long id = await manager.WatchAsync(KeyRequest("foo"), r => + { + received.Add(r); + if (r.Events.Count > 0) + { + fired.Set(); + } + }); + + Assert.Equal(FirstWatchId, id); + Assert.True(WaitFor(() => fake.Requests.Written.Any(w => w.CreateRequest != null)), + "Expected a WatchCreate request to be written to the stream."); + Assert.Equal(id, fake.Requests.Written.First().CreateRequest.WatchId); + + // Drive a created response (assigns server watch id mapping) then an event response. + fake.Enqueue(new WatchResponse { WatchId = id, Created = true }); + fake.Enqueue(EventResponse(id, "foo", "bar")); + + Assert.True(fired.Wait(2000), "Callback was not invoked with events within timeout."); + Assert.Contains(received, r => r.Created); + manager.Dispose(); + } + + [Fact] + public void Watch_Synchronous_FiresCallback() + { + var manager = CreateManager(out var fake); + using var fired = new ManualResetEventSlim(); + + long id = manager.Watch(KeyRequest("k"), r => + { + if (r.Events.Count > 0) + { + fired.Set(); + } + }); + + Assert.Equal(FirstWatchId, id); + fake.Enqueue(EventResponse(id, "k", "v")); + Assert.True(fired.Wait(2000)); + manager.Dispose(); + } + + // --------------------------------------------------------------------- + // Convenience key / prefix / startRevision overloads (WatchEvent callbacks) + // --------------------------------------------------------------------- + + [Fact] + public void Watch_Key_WatchEventCallback_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = manager.Watch("mykey", e => events.Add(e)); + + Assert.Equal(FirstWatchId, id); + var written = fake.Requests.Written.First().CreateRequest; + Assert.Equal("mykey", written.Key.ToStringUtf8()); + Assert.True(written.PrevKv); + + fake.Enqueue(EventResponse(id, "mykey", "val")); + Assert.True(WaitFor(() => events.Count > 0)); + Assert.Equal("mykey", events[0].Key); + Assert.Equal("val", events[0].Value); + manager.Dispose(); + } + + [Fact] + public void WatchRange_Prefix_WatchEventCallback_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = manager.WatchRange("pre", e => events.Add(e)); + + var written = fake.Requests.Written.First().CreateRequest; + Assert.Equal("pre", written.Key.ToStringUtf8()); + Assert.False(written.RangeEnd.IsEmpty); + + fake.Enqueue(EventResponse(id, "pre/x", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public void Watch_KeyWithStartRevision_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = manager.Watch("rk", 42, e => events.Add(e)); + + Assert.Equal(42, fake.Requests.Written.First().CreateRequest.StartRevision); + fake.Enqueue(EventResponse(id, "rk", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public void WatchRange_PrefixWithStartRevision_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = manager.WatchRange("rp", 7, e => events.Add(e)); + + var written = fake.Requests.Written.First().CreateRequest; + Assert.Equal(7, written.StartRevision); + Assert.False(written.RangeEnd.IsEmpty); + fake.Enqueue(EventResponse(id, "rp1", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public void WatchRange_Path_WatchResponseCallback_DeliversResponse() + { + var manager = CreateManager(out var fake); + var responses = new List(); + long id = manager.WatchRange("path", r => responses.Add(r)); + + var written = fake.Requests.Written.First().CreateRequest; + Assert.Equal("path", written.Key.ToStringUtf8()); + Assert.False(written.RangeEnd.IsEmpty); + fake.Enqueue(EventResponse(id, "path1", "v")); + Assert.True(WaitFor(() => responses.Count > 0)); + manager.Dispose(); + } + + // --------------------------------------------------------------------- + // Async convenience overloads + // --------------------------------------------------------------------- + + [Fact] + public async Task WatchAsync_Key_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = await manager.WatchAsync("ak", e => events.Add(e)); + fake.Enqueue(EventResponse(id, "ak", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public async Task WatchRangeAsync_Prefix_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = await manager.WatchRangeAsync("ap", e => events.Add(e)); + Assert.False(fake.Requests.Written.First().CreateRequest.RangeEnd.IsEmpty); + fake.Enqueue(EventResponse(id, "ap1", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public async Task WatchAsync_KeyWithStartRevision_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = await manager.WatchAsync("ak", 11, e => events.Add(e)); + Assert.Equal(11, fake.Requests.Written.First().CreateRequest.StartRevision); + fake.Enqueue(EventResponse(id, "ak", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + [Fact] + public async Task WatchRangeAsync_PrefixWithStartRevision_DeliversEvent() + { + var manager = CreateManager(out var fake); + var events = new List(); + long id = await manager.WatchRangeAsync("ap", 13, e => events.Add(e)); + var written = fake.Requests.Written.First().CreateRequest; + Assert.Equal(13, written.StartRevision); + Assert.False(written.RangeEnd.IsEmpty); + fake.Enqueue(EventResponse(id, "ap1", "v")); + Assert.True(WaitFor(() => events.Count > 0)); + manager.Dispose(); + } + + // --------------------------------------------------------------------- + // CancelWatch + // --------------------------------------------------------------------- + + [Fact] + public async Task CancelWatch_AfterCreated_SendsServerCancelRequest() + { + var manager = CreateManager(out var fake); + using var created = new ManualResetEventSlim(); + long id = await manager.WatchAsync(KeyRequest("c"), r => + { + if (r.Created) + { + created.Set(); + } + }); + + // Created response maps server watch id -> client watch id so cancel can find it. + // Wait until the receive loop has processed it (so the mapping exists) before cancelling. + fake.Enqueue(new WatchResponse { WatchId = id, Created = true }); + Assert.True(created.Wait(2000), "Created response was not processed in time."); + + manager.CancelWatch(id); + + Assert.True(WaitFor(() => fake.Requests.Written.Any(w => w.CancelRequest != null)), + "Expected a WatchCancel request to be written after CancelWatch."); + Assert.Equal(id, fake.Requests.Written.First(w => w.CancelRequest != null).CancelRequest.WatchId); + manager.Dispose(); + } + + [Fact] + public void CancelWatch_UnknownId_DoesNothing() + { + var manager = CreateManager(out _); + // No watches registered; should be a no-op and not throw. + manager.CancelWatch(99999); + manager.Dispose(); + } + + [Fact] + public async Task CanceledResponse_StopsDeliveringFurtherCallbacks() + { + var manager = CreateManager(out var fake); + int count = 0; + long id = await manager.WatchAsync(KeyRequest("x"), _ => Interlocked.Increment(ref count)); + + fake.Enqueue(new WatchResponse { WatchId = id, Canceled = true }); + Assert.True(WaitFor(() => count >= 1)); + manager.Dispose(); + } + + // --------------------------------------------------------------------- + // Dispose / ObjectDisposed + // --------------------------------------------------------------------- + + [Fact] + public async Task Dispose_ThenWatch_ThrowsObjectDisposedException() + { + var manager = CreateManager(out _); + manager.Dispose(); + + Assert.Throws(() => manager.Watch("k", (Action)(_ => { }))); + await Assert.ThrowsAsync(() => + manager.WatchAsync(KeyRequest("k"), _ => { })); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var manager = CreateManager(out _); + manager.Dispose(); + manager.Dispose(); + } + + [Fact] + public async Task Dispose_CancelsActiveWatchesAndDisposesStream() + { + var manager = CreateManager(out var fake); + await manager.WatchAsync(KeyRequest("d"), _ => { }); + Assert.True(WaitFor(() => fake.Requests.Written.Any())); + + manager.Dispose(); + Assert.True(fake.IsDisposed); + } + + // --------------------------------------------------------------------- + // Watcher (direct) coverage + // --------------------------------------------------------------------- + + [Fact] + public void Watcher_Constructor_NullStream_Throws() + { + Assert.Throws(() => + new Watcher(null!)); + } + + [Fact] + public async Task Watcher_CreateWatch_NullArgs_Throw() + { + var fake = new FakeDuplexStreamingCall(); + using var watcher = new Watcher(fake); + + await Assert.ThrowsAsync(() => watcher.CreateWatchAsync(null!, _ => { })); + await Assert.ThrowsAsync(() => watcher.CreateWatchAsync(KeyRequest("k"), null!)); + } + + [Fact] + public async Task Watcher_CancelWatch_WritesCancelAndRemovesCallback() + { + var fake = new FakeDuplexStreamingCall(); + using var watcher = new Watcher(fake); + + var request = KeyRequest("k"); + request.CreateRequest.WatchId = 5; + await watcher.CreateWatchAsync(request, _ => { }); + await watcher.CancelWatchAsync(5); + + Assert.Contains(fake.Requests.Written, w => w.CancelRequest != null && w.CancelRequest.WatchId == 5); + } + + [Fact] + public void Watcher_ConnectionFailure_OnRpcException_InvokesCallback() + { + var fake = new FakeDuplexStreamingCall(); + fake.Responses.ThrowOnMoveNext = + new RpcException(new Status(StatusCode.Unavailable, "connection lost")); + + using var failed = new ManualResetEventSlim(); + using var watcher = new Watcher(fake, () => failed.Set()); + + Assert.True(failed.Wait(2000), "onConnectionFailure was not invoked on RpcException."); + } + + [Fact] + public void Watcher_CancelledRpcException_DoesNotInvokeFailureCallback() + { + var fake = new FakeDuplexStreamingCall(); + fake.Responses.ThrowOnMoveNext = + new RpcException(new Status(StatusCode.Cancelled, "cancelled")); + + bool failureInvoked = false; + using var watcher = new Watcher(fake, () => failureInvoked = true); + + // Give the background loop time to observe the cancellation. + Thread.Sleep(200); + Assert.False(failureInvoked); + } + + [Fact] + public void Watcher_Dispose_DisposesUnderlyingStream() + { + var fake = new FakeDuplexStreamingCall(); + var watcher = new Watcher(fake); + watcher.Dispose(); + Assert.True(fake.IsDisposed); + } +} diff --git a/dotnet-etcd/watchclient/watchClient.cs b/dotnet-etcd/watchclient/watchClient.cs index 8ef4082..2d92ace 100644 --- a/dotnet-etcd/watchclient/watchClient.cs +++ b/dotnet-etcd/watchclient/watchClient.cs @@ -34,6 +34,11 @@ public async Task WatchAsync(WatchRequest[] requests, Action wrapper = response => { @@ -42,7 +47,7 @@ public async Task WatchAsync(WatchRequest[] requests, Action[] methods, Metad for (int i = 0; i < requests.Length; i++) { + // Capture the loop index per iteration; otherwise the closure below would read the + // shared loop variable (== requests.Length when an event later arrives) and throw + // IndexOutOfRangeException, silently dropping every event. + int index = i; + // Create a wrapper that converts WatchResponse to WatchEvent[] Action wrapper = response => { @@ -127,7 +137,7 @@ public void Watch(WatchRequest[] requests, Action[] methods, Metad Key = e.Kv.Key.ToStringUtf8(), Value = e.Kv.Value.ToStringUtf8(), Type = e.Type })]; - methods[i](events); + methods[index](events); }; _watchManager.Watch(requests[i], wrapper, headers, deadline, cancellationToken); From 7244d2aa9ad9a0adaceda9e6411ad0967c1c1877 Mon Sep 17 00:00:00 2001 From: Shubham Ranjan Date: Wed, 1 Jul 2026 00:13:55 +0530 Subject: [PATCH 5/5] ci: capture beast logs as artifact on scheduled-run failure The nightly beast schedule already runs, but per-iteration logs went to an ephemeral mktemp dir lost when the runner tears down. beast.sh now honors BEAST_LOG_DIR; the workflow points it at the workspace and uploads it as an artifact on failure, so a nightly flake is diagnosable. --- .github/workflows/beast-tests.yml | 11 +++++++++++ dotnet-etcd.Tests/beast.sh | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/beast-tests.yml b/.github/workflows/beast-tests.yml index 22d805e..6f96443 100644 --- a/.github/workflows/beast-tests.yml +++ b/.github/workflows/beast-tests.yml @@ -50,9 +50,20 @@ jobs: # into the shell. Defaults apply on the nightly schedule where inputs are null. ITER: ${{ github.event.inputs.iterations || '20' }} CAT: ${{ github.event.inputs.category || 'All' }} + # Capture per-iteration logs at a known path so a nightly flake is diagnosable. + BEAST_LOG_DIR: ${{ github.workspace }}/beast-logs run: | # Validate inputs defensively before use. case "$ITER" in (*[!0-9]*|'') echo "iterations must be a positive integer" >&2; exit 2 ;; esac case "$CAT" in (All|Unit|Integration) ;; (*) echo "category must be All|Unit|Integration" >&2; exit 2 ;; esac # -k: run every iteration and report totals rather than stopping on the first failure. dotnet-etcd.Tests/beast.sh -n "$ITER" -f "$CAT" -k + + - name: Upload beast logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: beast-logs + path: ${{ github.workspace }}/beast-logs + retention-days: 14 + if-no-files-found: ignore diff --git a/dotnet-etcd.Tests/beast.sh b/dotnet-etcd.Tests/beast.sh index 34bd6c6..af6c822 100755 --- a/dotnet-etcd.Tests/beast.sh +++ b/dotnet-etcd.Tests/beast.sh @@ -43,7 +43,10 @@ case "$FILTER" in esac PROJECT="dotnet-etcd.Tests.csproj" -LOG_DIR="$(mktemp -d)" +# Honor BEAST_LOG_DIR so CI can point logs at a known path and upload them as an artifact; +# default to an ephemeral temp dir for local runs. +LOG_DIR="${BEAST_LOG_DIR:-$(mktemp -d)}" +mkdir -p "$LOG_DIR" # Integration tests need etcd; bring it up if it isn't already serving. if [ "$FILTER" != "Unit" ]; then