diff --git a/src/Dapr.Client/DaprOutboxMetadata.cs b/src/Dapr.Client/DaprOutboxMetadata.cs new file mode 100644 index 000000000..8c2832bff --- /dev/null +++ b/src/Dapr.Client/DaprOutboxMetadata.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#nullable enable + +namespace Dapr.Client; + +/// +/// Well-known metadata keys used by Dapr's transactional outbox feature on +/// state transaction operations and by the . +/// +/// +/// See +/// How-To: Enable the transactional outbox pattern for details. +/// +public static class DaprOutboxMetadata +{ + /// + /// Marks a as an outbox projection. + /// The item is not written to the state store; its value is used as the payload + /// published to the pub/sub topic configured on the state store component. + /// + public const string Projection = "outbox.projection"; + + /// + /// Value used with to enable the projection behavior. + /// + public const string ProjectionEnabled = "true"; + + /// + /// Overrides the CloudEvent id field on the published outbox event. + /// + public const string CloudEventId = "cloudevent.id"; + + /// + /// Overrides the CloudEvent source field on the published outbox event. + /// + public const string CloudEventSource = "cloudevent.source"; + + /// + /// Overrides the CloudEvent type field on the published outbox event. + /// + public const string CloudEventType = "cloudevent.type"; + + /// + /// Overrides the CloudEvent subject field on the published outbox event. + /// + public const string CloudEventSubject = "cloudevent.subject"; + + /// + /// Overrides the CloudEvent datacontenttype field on the published outbox event. + /// + public const string CloudEventDataContentType = "cloudevent.datacontenttype"; +} diff --git a/src/Dapr.Client/OutboxTransactionBuilder.cs b/src/Dapr.Client/OutboxTransactionBuilder.cs new file mode 100644 index 000000000..c883a17c6 --- /dev/null +++ b/src/Dapr.Client/OutboxTransactionBuilder.cs @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#nullable enable +using System; +using System.Collections.Generic; + +namespace Dapr.Client; + +/// +/// Fluent builder that produces a list of items +/// shaped for Dapr's transactional outbox feature. Each entry pairs the state operation +/// that is persisted to the state store with an optional projection that shapes the +/// payload published to the pub/sub topic. +/// +/// +/// Dapr's outbox requires the projection key to match the state operation key exactly. +/// This builder enforces that invariant at build time. +/// +public sealed class OutboxTransactionBuilder +{ + private readonly List entries = new(); + + /// + /// Adds a state upsert whose value is also published as the outbox event. + /// + /// The state key. + /// The serialized state value, written to the store and used as the published payload. + /// Optional ETag for optimistic concurrency. + /// Optional metadata applied to the state operation. + /// Optional state options. + /// This builder, for chaining. + public OutboxTransactionBuilder Upsert( + string key, + byte[] value, + string? etag = null, + IReadOnlyDictionary? metadata = null, + StateOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentNullException.ThrowIfNull(value); + + entries.Add(new Entry( + new StateTransactionRequest(key, value, StateOperationType.Upsert, etag, metadata, options), + Projection: null)); + + return this; + } + + /// + /// Adds a state upsert paired with an explicit projection that shapes the outbox event + /// payload independently of the state value. + /// + /// The state key. The projection uses the same key. + /// The value persisted to the state store. + /// The value published as the outbox event payload. + /// Optional ETag for optimistic concurrency on the state write. + /// Optional metadata applied to the state operation. + /// + /// Optional metadata applied to the projection (for example, CloudEvent overrides from + /// ). The key + /// is always added. + /// + /// Optional state options for the state write. + /// This builder, for chaining. + public OutboxTransactionBuilder UpsertWithProjection( + string key, + byte[] stateValue, + byte[] projectionValue, + string? etag = null, + IReadOnlyDictionary? stateMetadata = null, + IReadOnlyDictionary? projectionMetadata = null, + StateOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentNullException.ThrowIfNull(stateValue); + ArgumentNullException.ThrowIfNull(projectionValue); + + var state = new StateTransactionRequest(key, stateValue, StateOperationType.Upsert, etag, stateMetadata, options); + var projection = new StateTransactionRequest(key, projectionValue, StateOperationType.Upsert, etag: null, metadata: projectionMetadata, options: null) + .WithOutboxProjection(); + + entries.Add(new Entry(state, projection)); + + return this; + } + + /// + /// Adds a state delete whose absence is signalled to the outbox topic. + /// + /// The state key. + /// Optional ETag for optimistic concurrency. + /// Optional metadata applied to the state operation. + /// Optional state options. + /// This builder, for chaining. + public OutboxTransactionBuilder Delete( + string key, + string? etag = null, + IReadOnlyDictionary? metadata = null, + StateOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + entries.Add(new Entry( + new StateTransactionRequest(key, value: null, StateOperationType.Delete, etag, metadata, options), + Projection: null)); + + return this; + } + + /// + /// Builds the flat list of items in the order + /// suitable for passing to . + /// State operations always precede their paired projections. + /// + /// An immutable list of transaction requests. + public IReadOnlyList Build() + { + var result = new List(entries.Count * 2); + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + result.Add(entry.State); + if (entry.Projection is not null) + { + if (!string.Equals(entry.State.Key, entry.Projection.Key, StringComparison.Ordinal)) + { + // Defensive: internally we always construct projections with the same key, + // so this should never happen. Guard prevents silent corruption if the + // internal invariant ever breaks. + throw new InvalidOperationException( + $"Outbox projection key '{entry.Projection.Key}' does not match state key '{entry.State.Key}'."); + } + + result.Add(entry.Projection); + } + } + + return result; + } + + private readonly record struct Entry(StateTransactionRequest State, StateTransactionRequest? Projection); +} diff --git a/src/Dapr.Client/StateTransactionRequestExtensions.cs b/src/Dapr.Client/StateTransactionRequestExtensions.cs new file mode 100644 index 000000000..40252d7ff --- /dev/null +++ b/src/Dapr.Client/StateTransactionRequestExtensions.cs @@ -0,0 +1,141 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#nullable enable +using System; +using System.Collections.Generic; + +namespace Dapr.Client; + +/// +/// Extension methods for shaping instances +/// for use with Dapr's transactional outbox feature. +/// +public static class StateTransactionRequestExtensions +{ + /// + /// Returns a new whose metadata marks it as an + /// outbox projection (see ). The source + /// request is not mutated. + /// + /// The state transaction request to convert into a projection. + /// A new with the projection metadata set. + public static StateTransactionRequest WithOutboxProjection(this StateTransactionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var merged = MergeMetadata(request.Metadata, new KeyValuePair[] + { + new(DaprOutboxMetadata.Projection, DaprOutboxMetadata.ProjectionEnabled), + }); + + return new StateTransactionRequest( + request.Key, + request.Value, + request.OperationType ?? StateOperationType.Upsert, + request.ETag, + merged, + request.Options); + } + + /// + /// Returns a new with the specified CloudEvent + /// fields set via metadata. Existing metadata is preserved; only the supplied fields + /// are overridden. The source request is not mutated. + /// + /// The state transaction request to enrich. + /// The CloudEvent id field, or to leave unchanged. + /// The CloudEvent source field, or to leave unchanged. + /// The CloudEvent type field, or to leave unchanged. + /// The CloudEvent subject field, or to leave unchanged. + /// The CloudEvent datacontenttype field, or to leave unchanged. + /// A new with the CloudEvent metadata merged in. + public static StateTransactionRequest WithCloudEventOverrides( + this StateTransactionRequest request, + string? id = null, + string? source = null, + string? type = null, + string? subject = null, + string? dataContentType = null) + { + ArgumentNullException.ThrowIfNull(request); + + var overrides = new List>(capacity: 5); + if (id is not null) + { + overrides.Add(new KeyValuePair(DaprOutboxMetadata.CloudEventId, id)); + } + if (source is not null) + { + overrides.Add(new KeyValuePair(DaprOutboxMetadata.CloudEventSource, source)); + } + if (type is not null) + { + overrides.Add(new KeyValuePair(DaprOutboxMetadata.CloudEventType, type)); + } + if (subject is not null) + { + overrides.Add(new KeyValuePair(DaprOutboxMetadata.CloudEventSubject, subject)); + } + if (dataContentType is not null) + { + overrides.Add(new KeyValuePair(DaprOutboxMetadata.CloudEventDataContentType, dataContentType)); + } + + if (overrides.Count == 0) + { + // Nothing to change; still return a new instance to keep the API purely non-mutating + // regardless of caller expectations. + return new StateTransactionRequest( + request.Key, + request.Value, + request.OperationType ?? StateOperationType.Upsert, + request.ETag, + request.Metadata, + request.Options); + } + + var merged = MergeMetadata(request.Metadata, overrides); + + return new StateTransactionRequest( + request.Key, + request.Value, + request.OperationType ?? StateOperationType.Upsert, + request.ETag, + merged, + request.Options); + } + + private static IReadOnlyDictionary MergeMetadata( + IReadOnlyDictionary? existing, + IReadOnlyList> overrides) + { + var merged = new Dictionary(StringComparer.Ordinal); + + if (existing is not null) + { + foreach (var kvp in existing) + { + merged[kvp.Key] = kvp.Value; + } + } + + for (var i = 0; i < overrides.Count; i++) + { + var kvp = overrides[i]; + merged[kvp.Key] = kvp.Value; + } + + return merged; + } +} diff --git a/test/Dapr.Client.Test/DaprOutboxMetadataTests.cs b/test/Dapr.Client.Test/DaprOutboxMetadataTests.cs new file mode 100644 index 000000000..1236f0b6d --- /dev/null +++ b/test/Dapr.Client.Test/DaprOutboxMetadataTests.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Client.Test; + +using Shouldly; +using Xunit; + +public class DaprOutboxMetadataTests +{ + [Fact] + public void Constants_MatchDaprRuntimeContract() + { + // These string values are the wire contract used by the Dapr sidecar's + // outbox implementation. If they change, the SDK stops interoperating + // with the runtime. This test locks them. + DaprOutboxMetadata.Projection.ShouldBe("outbox.projection"); + DaprOutboxMetadata.ProjectionEnabled.ShouldBe("true"); + DaprOutboxMetadata.CloudEventId.ShouldBe("cloudevent.id"); + DaprOutboxMetadata.CloudEventSource.ShouldBe("cloudevent.source"); + DaprOutboxMetadata.CloudEventType.ShouldBe("cloudevent.type"); + DaprOutboxMetadata.CloudEventSubject.ShouldBe("cloudevent.subject"); + DaprOutboxMetadata.CloudEventDataContentType.ShouldBe("cloudevent.datacontenttype"); + } +} diff --git a/test/Dapr.Client.Test/OutboxTransactionBuilderTests.cs b/test/Dapr.Client.Test/OutboxTransactionBuilderTests.cs new file mode 100644 index 000000000..78d70f7dd --- /dev/null +++ b/test/Dapr.Client.Test/OutboxTransactionBuilderTests.cs @@ -0,0 +1,159 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Client.Test; + +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +public class OutboxTransactionBuilderTests +{ + [Fact] + public void Build_WithUpsertOnly_ReturnsSingleRequest() + { + var result = new OutboxTransactionBuilder() + .Upsert("key1", new byte[] { 1, 2, 3 }) + .Build(); + + result.Count.ShouldBe(1); + result[0].Key.ShouldBe("key1"); + result[0].OperationType.ShouldBe(StateOperationType.Upsert); + result[0].Metadata.ShouldBeNull(); + } + + [Fact] + public void Build_WithUpsertWithProjection_EmitsPairedRequests_InCorrectOrder() + { + var result = new OutboxTransactionBuilder() + .UpsertWithProjection( + key: "key1", + stateValue: new byte[] { 1 }, + projectionValue: new byte[] { 2 }) + .Build(); + + result.Count.ShouldBe(2); + + // State operation first + result[0].Key.ShouldBe("key1"); + result[0].Value.ShouldBe(new byte[] { 1 }); + result[0].Metadata.ShouldBeNull(); + + // Projection second, with outbox.projection = true + result[1].Key.ShouldBe("key1"); + result[1].Value.ShouldBe(new byte[] { 2 }); + result[1].Metadata.ShouldNotBeNull(); + result[1].Metadata![DaprOutboxMetadata.Projection].ShouldBe(DaprOutboxMetadata.ProjectionEnabled); + } + + [Fact] + public void Build_WithProjectionMetadata_MergesCloudEventOverrides() + { + var projectionMeta = new Dictionary + { + [DaprOutboxMetadata.CloudEventType] = "OrderPlaced", + [DaprOutboxMetadata.CloudEventSource] = "orders", + }; + + var result = new OutboxTransactionBuilder() + .UpsertWithProjection( + key: "order-42", + stateValue: new byte[] { 1 }, + projectionValue: new byte[] { 2 }, + projectionMetadata: projectionMeta) + .Build(); + + var projection = result[1]; + projection.Metadata![DaprOutboxMetadata.Projection].ShouldBe(DaprOutboxMetadata.ProjectionEnabled); + projection.Metadata![DaprOutboxMetadata.CloudEventType].ShouldBe("OrderPlaced"); + projection.Metadata![DaprOutboxMetadata.CloudEventSource].ShouldBe("orders"); + } + + [Fact] + public void Build_WithDelete_EmitsDeleteRequest() + { + var result = new OutboxTransactionBuilder() + .Delete("key1", etag: "abc") + .Build(); + + result.Count.ShouldBe(1); + result[0].Key.ShouldBe("key1"); + result[0].OperationType.ShouldBe(StateOperationType.Delete); + result[0].Value.ShouldBeNull(); + result[0].ETag.ShouldBe("abc"); + } + + [Fact] + public void Build_WithMultipleEntries_PreservesOrder() + { + var result = new OutboxTransactionBuilder() + .Upsert("a", new byte[] { 1 }) + .UpsertWithProjection("b", new byte[] { 2 }, new byte[] { 3 }) + .Delete("c") + .Build(); + + result.Count.ShouldBe(4); + result[0].Key.ShouldBe("a"); + result[1].Key.ShouldBe("b"); + result[1].Metadata.ShouldBeNull(); + result[2].Key.ShouldBe("b"); + result[2].Metadata![DaprOutboxMetadata.Projection].ShouldBe(DaprOutboxMetadata.ProjectionEnabled); + result[3].Key.ShouldBe("c"); + result[3].OperationType.ShouldBe(StateOperationType.Delete); + } + + [Fact] + public void Upsert_ThrowsOnNullOrEmptyKey() + { + var builder = new OutboxTransactionBuilder(); + Should.Throw(() => builder.Upsert("", new byte[] { 1 })); + Should.Throw(() => builder.Upsert(null!, new byte[] { 1 })); + } + + [Fact] + public void Upsert_ThrowsOnNullValue() + { + var builder = new OutboxTransactionBuilder(); + Should.Throw(() => builder.Upsert("k", null!)); + } + + [Fact] + public void UpsertWithProjection_ThrowsOnNullOrEmptyKey() + { + var builder = new OutboxTransactionBuilder(); + Should.Throw(() => builder.UpsertWithProjection("", new byte[] { 1 }, new byte[] { 2 })); + } + + [Fact] + public void UpsertWithProjection_ThrowsOnNullValues() + { + var builder = new OutboxTransactionBuilder(); + Should.Throw(() => builder.UpsertWithProjection("k", null!, new byte[] { 2 })); + Should.Throw(() => builder.UpsertWithProjection("k", new byte[] { 1 }, null!)); + } + + [Fact] + public void Delete_ThrowsOnNullOrEmptyKey() + { + var builder = new OutboxTransactionBuilder(); + Should.Throw(() => builder.Delete("")); + Should.Throw(() => builder.Delete(null!)); + } + + [Fact] + public void Build_OnEmptyBuilder_ReturnsEmptyList() + { + new OutboxTransactionBuilder().Build().Count.ShouldBe(0); + } +} diff --git a/test/Dapr.Client.Test/StateTransactionRequestExtensionsTests.cs b/test/Dapr.Client.Test/StateTransactionRequestExtensionsTests.cs new file mode 100644 index 000000000..581511959 --- /dev/null +++ b/test/Dapr.Client.Test/StateTransactionRequestExtensionsTests.cs @@ -0,0 +1,148 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Client.Test; + +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +public class StateTransactionRequestExtensionsTests +{ + [Fact] + public void WithOutboxProjection_AddsProjectionMetadata_WithoutMutatingSource() + { + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert); + + var projection = source.WithOutboxProjection(); + + projection.ShouldNotBeSameAs(source); + projection.Metadata.ShouldNotBeNull(); + projection.Metadata![DaprOutboxMetadata.Projection].ShouldBe(DaprOutboxMetadata.ProjectionEnabled); + source.Metadata.ShouldBeNull(); + } + + [Fact] + public void WithOutboxProjection_PreservesExistingMetadata() + { + var existing = new Dictionary { ["ttlInSeconds"] = "60" }; + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert, metadata: existing); + + var projection = source.WithOutboxProjection(); + + projection.Metadata!["ttlInSeconds"].ShouldBe("60"); + projection.Metadata![DaprOutboxMetadata.Projection].ShouldBe(DaprOutboxMetadata.ProjectionEnabled); + } + + [Fact] + public void WithOutboxProjection_PreservesKeyValueEtagAndOptions() + { + var options = new StateOptions { Consistency = ConsistencyMode.Strong, Concurrency = ConcurrencyMode.FirstWrite }; + var source = new StateTransactionRequest( + "key1", + new byte[] { 1, 2, 3 }, + StateOperationType.Upsert, + etag: "abc", + options: options); + + var projection = source.WithOutboxProjection(); + + projection.Key.ShouldBe(source.Key); + projection.Value.ShouldBe(source.Value); + projection.ETag.ShouldBe(source.ETag); + projection.Options.ShouldBe(source.Options); + projection.OperationType.ShouldBe(source.OperationType); + } + + [Fact] + public void WithOutboxProjection_ThrowsWhenRequestIsNull() + { + StateTransactionRequest source = null!; + + Should.Throw(() => source.WithOutboxProjection()); + } + + [Fact] + public void WithCloudEventOverrides_MergesSuppliedFields() + { + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert); + + var enriched = source.WithCloudEventOverrides( + id: "evt-1", + source: "orders", + type: "OrderPlaced", + subject: "order/42", + dataContentType: "application/json"); + + enriched.Metadata![DaprOutboxMetadata.CloudEventId].ShouldBe("evt-1"); + enriched.Metadata![DaprOutboxMetadata.CloudEventSource].ShouldBe("orders"); + enriched.Metadata![DaprOutboxMetadata.CloudEventType].ShouldBe("OrderPlaced"); + enriched.Metadata![DaprOutboxMetadata.CloudEventSubject].ShouldBe("order/42"); + enriched.Metadata![DaprOutboxMetadata.CloudEventDataContentType].ShouldBe("application/json"); + } + + [Fact] + public void WithCloudEventOverrides_LeavesUnspecifiedFieldsAlone() + { + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert); + + var enriched = source.WithCloudEventOverrides(id: "evt-1"); + + enriched.Metadata!.ContainsKey(DaprOutboxMetadata.CloudEventId).ShouldBeTrue(); + enriched.Metadata!.ContainsKey(DaprOutboxMetadata.CloudEventSource).ShouldBeFalse(); + enriched.Metadata!.ContainsKey(DaprOutboxMetadata.CloudEventType).ShouldBeFalse(); + } + + [Fact] + public void WithCloudEventOverrides_PreservesExistingMetadata() + { + var existing = new Dictionary { ["ttlInSeconds"] = "60" }; + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert, metadata: existing); + + var enriched = source.WithCloudEventOverrides(id: "evt-1"); + + enriched.Metadata!["ttlInSeconds"].ShouldBe("60"); + enriched.Metadata![DaprOutboxMetadata.CloudEventId].ShouldBe("evt-1"); + } + + [Fact] + public void WithCloudEventOverrides_DoesNotMutateSource() + { + var existing = new Dictionary { ["ttlInSeconds"] = "60" }; + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert, metadata: existing); + + _ = source.WithCloudEventOverrides(id: "evt-1"); + + source.Metadata!.ContainsKey(DaprOutboxMetadata.CloudEventId).ShouldBeFalse(); + source.Metadata!.Count.ShouldBe(1); + } + + [Fact] + public void WithCloudEventOverrides_ThrowsWhenRequestIsNull() + { + StateTransactionRequest source = null!; + + Should.Throw(() => source.WithCloudEventOverrides(id: "evt-1")); + } + + [Fact] + public void WithCloudEventOverrides_WithNoValues_StillReturnsNewInstance() + { + var source = new StateTransactionRequest("key1", new byte[] { 1 }, StateOperationType.Upsert); + + var result = source.WithCloudEventOverrides(); + + result.ShouldNotBeSameAs(source); + } +}