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);
+ }
+}