diff --git a/src/Couchbase/Client/Transactions/AttemptContext.cs b/src/Couchbase/Client/Transactions/AttemptContext.cs index 6f8b07ced..191a2b877 100644 --- a/src/Couchbase/Client/Transactions/AttemptContext.cs +++ b/src/Couchbase/Client/Transactions/AttemptContext.cs @@ -1561,7 +1561,7 @@ await RepeatUntilSuccessOrThrow(async () => await _testHooks.BeforeDocCommitted(this, sm.Doc.Id).CAF(); var (updatedCas, mutationToken) = await _docs - .UnstageInsertOrReplace(sm.Doc.Collection, sm.Doc.Id, cas, content, insertMode, sm.Flags ?? new Flags(), sm.Expiry).CAF(); + .UnstageInsertOrReplace(sm.Doc.Collection, sm.Doc.Id, cas, content, insertMode, sm.Flags ?? Flags.JsonCommonFlags, sm.Expiry).CAF(); Logger.LogInformation( "Unstaged mutation successfully on {redactedId}, attempt={attemptId}, insertMode={insertMode}, ambiguityResolutionMode={ambiguityResolutionMode}, preCas={cas}, postCas={updatedCas}", Redactor.UserData(sm.Doc.FullyQualifiedId), diff --git a/src/Couchbase/Client/Transactions/Cleanup/Cleaner.cs b/src/Couchbase/Client/Transactions/Cleanup/Cleaner.cs index 11624f5ed..3a5d1349a 100644 --- a/src/Couchbase/Client/Transactions/Cleanup/Cleaner.cs +++ b/src/Couchbase/Client/Transactions/Cleanup/Cleaner.cs @@ -9,6 +9,7 @@ using Couchbase.Client.Transactions.DataAccess; using Couchbase.Client.Transactions.DataModel; using Couchbase.Client.Transactions.Error; +using Couchbase.Client.Transactions.Internal; using Couchbase.Client.Transactions.Internal.Test; using Couchbase.Client.Transactions.LogUtil; using Couchbase.Client.Transactions.Support; @@ -230,11 +231,14 @@ private async Task UnstageInsertOrRemove(DocumentLookupResult op, T content, var coll = op.DocumentCollection; if (op.IsDeleted) { + // InsertAsync has no flags option; the persisted flags are whatever the transcoder's + // GetFormat returns. Pin them to the staged flags (txn.aux.uf) — we did not stage + // this doc, so re-deriving from the transcoder would not preserve the user's flags. await coll.InsertAsync(op.Id, content, options => { options.Durability(durabilityLevel) - .Transcoder(op.StagedContent?.Transcoder); + .Transcoder(new FixedFlagsTranscoder(op.StagedContent!.Transcoder, op.StagedContent.Flags)); if (op.Expiry.HasValue) options.Expiry(op.Expiry.Value.RemainingTtl()); }).CAF(); } @@ -246,7 +250,9 @@ await coll.MutateInAsync(op.Id, specs => { opts.Durability(durabilityLevel) .Transcoder(op.StagedContent?.Transcoder) - .Flags(op.StagedContent!.Transcoder.GetFormat(content)) + // Use the flags recorded at staging time (txn.aux.uf), not flags + // re-derived from this cleaner's transcoder — we did not stage this doc. + .Flags(op.StagedContent!.Flags) .Timeout(_keyValueTimeout) .Cas(op.Cas) .PreserveTtl(coll.Scope.Bucket.SupportsCollections); diff --git a/src/Couchbase/Client/Transactions/DataAccess/DocumentRepository.cs b/src/Couchbase/Client/Transactions/DataAccess/DocumentRepository.cs index a35d6e350..593ddf943 100644 --- a/src/Couchbase/Client/Transactions/DataAccess/DocumentRepository.cs +++ b/src/Couchbase/Client/Transactions/DataAccess/DocumentRepository.cs @@ -1,6 +1,5 @@ #nullable enable using System; -using System.Buffers.Binary; using System.Collections.Generic; using System.Threading.Tasks; using Couchbase.Core; @@ -177,10 +176,13 @@ public bool SupportsReplaceBodyWithXattr(ICouchbaseCollection collection) // if bucket doesn't support ReplaceBodyWithXattr (and ReviveDocument) if (insertMode) { + // InsertAsync has no flags option; the persisted flags are whatever the transcoder's + // GetFormat returns. Pin them to the staged flags so the committed doc keeps them. + ITypeTranscoder insertTranscoder = isBinary + ? new RawBinaryTranscoder() + : _jsonUserDataTranscoder; var opts = new InsertOptions().Defaults(_durability, _keyValueTimeout) - .Transcoder(isBinary - ? new RawBinaryTranscoder() - : _jsonUserDataTranscoder); + .Transcoder(new FixedFlagsTranscoder(insertTranscoder, flags)); if (expiry.HasValue) { opts.Expiry(expiry.Value.RemainingTtl()); @@ -289,6 +291,12 @@ internal static async Task LookupDocumentAsync(ICouchbaseC var docMeta = lookupInResult.ContentAs(docMetaIndex); int dataIdx = stagedDataIndex; // just need a default + // Deserialize the transaction xattr once; reused below for the staged user-flags + // (txn.aux.uf) and stored on the result. + var txnXattrs = lookupInResult.Exists(txnIndex) + ? lookupInResult.ContentAs(txnIndex) + : null; + // Determine the appropriate transcoder for wrapping the document content. // This is separate from the lookup transcoder used above. // Note: We use separate transcoders for staged vs unstaged content because: @@ -340,8 +348,12 @@ internal static async Task LookupDocumentAsync(ICouchbaseC ? new LookupInContentAsWrapper(lookupInResult, fullDocIndex.Value, unstagedContentTranscoder) : null; + // Staged content carries the user flags recorded in txn.aux.uf when it was staged, + // NOT the live document body's flags. This matters when committing content we didn't + // stage (lost-transaction cleanup) or when resolving ambiguity from a re-read doc. var stagedContent = lookupInResult.Exists(dataIdx) - ? new LookupInContentAsWrapper(lookupInResult, dataIdx, stagedContentTranscoder) + ? new LookupInContentAsWrapper(lookupInResult, dataIdx, stagedContentTranscoder, + flagsOverride: ParseStagedUserFlags(txnXattrs)) : null; var result = new DocumentLookupResult(docId, @@ -351,12 +363,27 @@ internal static async Task LookupDocumentAsync(ICouchbaseC docMeta, collection); - if (lookupInResult.Exists(txnIndex)) + result.TransactionXattrs = txnXattrs; + + return result; + } + + /// + /// Reconstruct the user flags recorded in txn.aux.uf at staging time. Falls back to + /// JSON common flags when the field is absent (e.g. staged by an older/other SDK that did + /// not record it — such content was always JSON), mirroring Java's + /// stagedUserFlags().orElse(CodecFlags.JSON_COMMON_FLAGS). + /// + internal static Flags ParseStagedUserFlags(TransactionXattrs? txnXattrs) + { + if (txnXattrs?.AuxiliaryData is { ValueKind: JsonValueKind.Object } aux + && aux.TryGetProperty("uf", out var ufElement) + && ufElement.TryGetUInt32(out var uf)) { - result.TransactionXattrs = lookupInResult.ContentAs(txnIndex); + return Flags.FromUInt32(uf); } - return result; + return Flags.JsonCommonFlags; } private MutateInOptions GetMutateInOptions(StoreSemantics storeSemantics, ICouchbaseCollection collection) => @@ -414,11 +441,8 @@ private List CreateMutationSpecs(IAtrRepository atr, string opType specs.Add(MutateInSpec.Upsert(TransactionFields.StagedData, rawJsonElement, createPath: true, isXattr: true)); } - // convert flags to a uint - Span span = stackalloc byte[4]; - content.Flags.Write(span); - var flagCompact = BinaryPrimitives.ReadUInt32LittleEndian(span); - // now add it + // record the user flags (reconstructed on read by Flags.FromUInt32) + var flagCompact = content.Flags.ToUInt32(); specs.Add(MutateInSpec.Upsert(TransactionFields.UserFlags, flagCompact, createPath: true, isXattr: true)); break; diff --git a/src/Couchbase/Client/Transactions/Internal/FixedFlagsTranscoder.cs b/src/Couchbase/Client/Transactions/Internal/FixedFlagsTranscoder.cs new file mode 100644 index 000000000..6c57583da --- /dev/null +++ b/src/Couchbase/Client/Transactions/Internal/FixedFlagsTranscoder.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; +using System.IO; +using Couchbase.Core.IO.Operations; +using Couchbase.Core.IO.Serializers; +using Couchbase.Core.IO.Transcoders; + +namespace Couchbase.Client.Transactions.Internal +{ + /// + /// A transcoder decorator that pins the flags written to the document, delegating the actual + /// byte encoding/decoding to an inner transcoder. + /// + /// On a .NET mutation the persisted flags are always Transcoder.GetFormat(content) + /// (see OperationBase<T>.WriteExtras) — there is no per-operation flags override, + /// which is why InsertOptions exposes none. When committing content we did not stage + /// (lost-transaction cleanup) or via the legacy insert path, we must persist the user flags + /// recorded at staging time (txn.aux.uf) rather than flags re-derived from the content. + /// This wrapper makes report those staged flags so they land on the + /// document, while / behave exactly as the inner + /// transcoder. It is the .NET analogue of Java passing stagedUserFlags straight to the + /// insert request. + /// + /// + internal sealed class FixedFlagsTranscoder : ITypeTranscoder + { + private readonly ITypeTranscoder _inner; + private readonly Flags _flags; + + public FixedFlagsTranscoder(ITypeTranscoder inner, Flags flags) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _flags = flags; + } + + /// Always reports the fixed (staged) flags, ignoring the content's runtime type. + public Flags GetFormat(T value) => _flags; + + public void Encode(Stream stream, T value, Flags flags, OpCode opcode) => + _inner.Encode(stream, value, flags, opcode); + + public T? Decode(ReadOnlyMemory buffer, Flags flags, OpCode opcode) => + _inner.Decode(buffer, flags, opcode); + + public ITypeSerializer? Serializer + { + get => _inner.Serializer; + set => _inner.Serializer = value; + } + } +} diff --git a/src/Couchbase/Client/Transactions/Internal/IContentAsWrapper.cs b/src/Couchbase/Client/Transactions/Internal/IContentAsWrapper.cs index 64b995b17..feecee940 100644 --- a/src/Couchbase/Client/Transactions/Internal/IContentAsWrapper.cs +++ b/src/Couchbase/Client/Transactions/Internal/IContentAsWrapper.cs @@ -93,16 +93,18 @@ internal class LookupInContentAsWrapper : IContentAsWrapper public bool IsBinary { get; init; } - public LookupInContentAsWrapper(ILookupInResult lookupInResult, int specIndex, ITypeTranscoder? transcoder = null) + public LookupInContentAsWrapper(ILookupInResult lookupInResult, int specIndex, ITypeTranscoder? transcoder = null, Flags? flagsOverride = null) { _lookupInResult = lookupInResult; if (lookupInResult is not ILookupInResultInternal res) { throw new InvalidArgumentException("lookupInResult is not a LookupInResult"); } - // NOTE: this Flags isn't necessarily what we want to use for the flags if this specIndex - // becomes the document body. - Flags = res.Flags; + // res.Flags is the flags of the top-level (live) document body. That is correct for + // pre-transaction (unstaged) content, but wrong for staged content: the staged user + // flags are recorded separately in txn.aux.uf. Callers wrapping staged content pass + // flagsOverride so this wrapper carries the flags the content was actually staged with. + Flags = flagsOverride ?? res.Flags; _specIndex = specIndex; IsBinary = (res.Specs[specIndex].PathFlags & SubdocPathFlags.BinaryValue) != 0; Transcoder = transcoder ?? new JsonTranscoder(); diff --git a/src/Couchbase/Core/IO/Operations/Flags.cs b/src/Couchbase/Core/IO/Operations/Flags.cs index ab96dd2a4..e0dcd5d5d 100644 --- a/src/Couchbase/Core/IO/Operations/Flags.cs +++ b/src/Couchbase/Core/IO/Operations/Flags.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers.Binary; using System.Runtime.InteropServices; using Couchbase.Core.IO.Converters; @@ -15,6 +16,19 @@ public struct Flags internal const int Size = 4; // we explicitly serialize into 4 bytes + /// + /// The canonical common flags for JSON content (a JSON object). Used as a fallback + /// when a persisted flags value is unavailable (e.g. a staged transaction mutation + /// written by an older/other SDK that did not record txn.aux.uf). Mirrors what + /// emits for an object body. + /// + internal static Flags JsonCommonFlags => new() + { + DataFormat = DataFormat.Json, + Compression = Compression.None, + TypeCode = TypeCode.Object + }; + /// /// Read flags from a buffer. The buffer must be at least 4 bytes long. /// @@ -51,6 +65,33 @@ internal readonly void Write(Span buffer) ByteConverter.FromUInt16((ushort)TypeCode, buffer.Slice(2)); } + /// + /// Encode these flags as the 32-bit "user flags" integer used to persist them in a document + /// xattr (e.g. the transaction txn.aux.uf staged user-flags field). Uses network byte + /// order (big-endian) so the common-flags/format nibble lands in the top byte, matching the + /// Common Flags SDK RFC and the other SDKs — i.e. (ToUInt32() >> 24) & 0xF is the + /// document format. already emits that byte first, so a big-endian read + /// places it in the most-significant byte. + /// + internal readonly uint ToUInt32() + { + Span span = stackalloc byte[Size]; + Write(span); + return BinaryPrimitives.ReadUInt32BigEndian(span); + } + + /// + /// The exact inverse of : reconstruct flags from a persisted network + /// byte order (big-endian) 32-bit user-flags value. Round-trips byte-for-byte with + /// . + /// + internal static Flags FromUInt32(uint value) + { + Span span = stackalloc byte[Size]; + BinaryPrimitives.WriteUInt32BigEndian(span, value); + return Read(span); + } + private static void ThrowArgumentException() { throw new ArgumentException("buffer must be at least 4 bytes."); diff --git a/tests/Couchbase.UnitTests/Core/IO/Operations/FlagsTests.cs b/tests/Couchbase.UnitTests/Core/IO/Operations/FlagsTests.cs index 5f00a1022..0fa1543af 100644 --- a/tests/Couchbase.UnitTests/Core/IO/Operations/FlagsTests.cs +++ b/tests/Couchbase.UnitTests/Core/IO/Operations/FlagsTests.cs @@ -76,5 +76,60 @@ public void Write_ValidBuffer_ExpectedResult(byte[] expectedOutput, DataFormat d } #endregion + + #region ToUInt32 / FromUInt32 + + [Theory] + [InlineData(DataFormat.Json, TypeCode.Object)] + [InlineData(DataFormat.Json, TypeCode.String)] + [InlineData(DataFormat.Binary, TypeCode.Object)] + [InlineData(DataFormat.Private, TypeCode.String)] + [InlineData(DataFormat.String, TypeCode.String)] + public void ToUInt32_FromUInt32_RoundTrips(DataFormat dataFormat, TypeCode typeCode) + { + // Arrange + + var flags = new Flags + { + DataFormat = dataFormat, + Compression = Couchbase.Core.IO.Operations.Compression.None, + TypeCode = typeCode + }; + + // Act + + var roundTripped = Flags.FromUInt32(flags.ToUInt32()); + + // Assert + + Assert.Equal(flags.DataFormat, roundTripped.DataFormat); + Assert.Equal(flags.Compression, roundTripped.Compression); + Assert.Equal(flags.TypeCode, roundTripped.TypeCode); + } + + [Fact] + public void ToUInt32_UsesNetworkByteOrder_CommonFlagsInTopByte() + { + // JSON + Object writes bytes [0x02, 0x00, 0x00, 0x01]; read big-endian => 0x02000001. + // The common-flags/format nibble must be in the top byte so cross-SDK readers get it + // via (uf >> 24) & 0xF — matching Java's CodecFlags (format << 24). + var flags = Flags.JsonCommonFlags; + + Assert.Equal(0x02000001u, flags.ToUInt32()); + Assert.Equal((uint)DataFormat.Json, flags.ToUInt32() >> 24); + Assert.Equal(DataFormat.Json, Flags.FromUInt32(0x02000001u).DataFormat); + } + + [Fact] + public void JsonCommonFlags_IsJsonObject() + { + var flags = Flags.JsonCommonFlags; + + Assert.Equal(DataFormat.Json, flags.DataFormat); + Assert.Equal(Couchbase.Core.IO.Operations.Compression.None, flags.Compression); + Assert.Equal(TypeCode.Object, flags.TypeCode); + } + + #endregion } } diff --git a/tests/Couchbase.UnitTests/Transactions/FixedFlagsTranscoderTests.cs b/tests/Couchbase.UnitTests/Transactions/FixedFlagsTranscoderTests.cs new file mode 100644 index 000000000..673b77d0e --- /dev/null +++ b/tests/Couchbase.UnitTests/Transactions/FixedFlagsTranscoderTests.cs @@ -0,0 +1,82 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using Couchbase.Client.Transactions.Internal; +using Couchbase.Core.IO.Operations; +using Couchbase.Core.IO.Serializers; +using Couchbase.Core.IO.Transcoders; +using Xunit; + +namespace Couchbase.UnitTests.Transactions; + +/// +/// NCBC-4261: FixedFlagsTranscoder pins the flags written to a document (used on the raw-insert +/// commit paths, which have no flags option) while delegating byte encoding/decoding to the inner +/// transcoder. +/// +public class FixedFlagsTranscoderTests +{ + private static JsonTranscoder InnerJson() => new(SystemTextJsonSerializer.Create()); + + [Fact] + public void GetFormat_AlwaysReturnsFixedFlags_IgnoringContentType() + { + // Inner would report Json for an object and Binary for a byte[]; the decorator must not. + var fixedFlags = new Flags { DataFormat = DataFormat.Binary, TypeCode = System.TypeCode.String }; + var transcoder = new FixedFlagsTranscoder(InnerJson(), fixedFlags); + + var forObject = transcoder.GetFormat(new { a = 1 }); + var forBytes = transcoder.GetFormat(new byte[] { 1, 2, 3 }); + + Assert.Equal(DataFormat.Binary, forObject.DataFormat); + Assert.Equal(System.TypeCode.String, forObject.TypeCode); + Assert.Equal(DataFormat.Binary, forBytes.DataFormat); + Assert.Equal(System.TypeCode.String, forBytes.TypeCode); + } + + [Fact] + public void Encode_DelegatesToInner() + { + var inner = InnerJson(); + var content = new { key = "value" }; + var flags = new Flags { DataFormat = DataFormat.Json, TypeCode = System.TypeCode.Object }; + var transcoder = new FixedFlagsTranscoder(inner, Flags.JsonCommonFlags); + + using var innerStream = new MemoryStream(); + inner.Encode(innerStream, content, flags, OpCode.Set); + + using var decoratedStream = new MemoryStream(); + transcoder.Encode(decoratedStream, content, flags, OpCode.Set); + + Assert.Equal(innerStream.ToArray(), decoratedStream.ToArray()); + } + + [Fact] + public void Decode_DelegatesToInner_RoundTrips() + { + var inner = InnerJson(); + var flags = new Flags { DataFormat = DataFormat.Json, TypeCode = System.TypeCode.Object }; + var transcoder = new FixedFlagsTranscoder(inner, Flags.JsonCommonFlags); + + using var stream = new MemoryStream(); + inner.Encode(stream, new { number = 42 }, flags, OpCode.Set); + + var decoded = transcoder.Decode>(stream.ToArray(), flags, OpCode.Get); + + Assert.NotNull(decoded); + Assert.Equal(42, decoded!["number"]); + } + + [Fact] + public void Serializer_DelegatesToInner() + { + var inner = InnerJson(); + var transcoder = new FixedFlagsTranscoder(inner, Flags.JsonCommonFlags); + + Assert.Same(inner.Serializer, transcoder.Serializer); + + var newSerializer = SystemTextJsonSerializer.Create(); + transcoder.Serializer = newSerializer; + Assert.Same(newSerializer, inner.Serializer); + } +} diff --git a/tests/Couchbase.UnitTests/Transactions/StagedUserFlagsTests.cs b/tests/Couchbase.UnitTests/Transactions/StagedUserFlagsTests.cs new file mode 100644 index 000000000..564fa9da3 --- /dev/null +++ b/tests/Couchbase.UnitTests/Transactions/StagedUserFlagsTests.cs @@ -0,0 +1,178 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Couchbase.Client.Transactions.DataAccess; +using Couchbase.Client.Transactions.DataModel; +using Couchbase.Core.IO.Operations; +using Couchbase.Core.IO.Serializers; +using Couchbase.Core.IO.Transcoders; +using Couchbase.KeyValue; +using Moq; +using Xunit; + +namespace Couchbase.UnitTests.Transactions; + +/// +/// NCBC-4261: on commit we must use the user flags recorded in txn.aux.uf at staging time, +/// not the live document body's flags. These cover the parse/fallback helper and that the +/// staged content wrapper surfaces the staged flags end-to-end through LookupDocumentAsync. +/// +public class StagedUserFlagsTests +{ + private static readonly ReadOnlyMemory JsonBytes = + Encoding.UTF8.GetBytes("""{"key":"value"}"""); + private static readonly ReadOnlyMemory BinaryBytes = new byte[] { 0x01, 0x02, 0x03 }; + + private static TransactionXattrs XattrsWithAux(string? auxJson) => new() + { + AuxiliaryData = auxJson is null + ? null + : JsonDocument.Parse(auxJson).RootElement.Clone() + }; + + // Builds an aux JSON object carrying the user flags for the given format, computed via ToUInt32 + // so the test stays correct regardless of the (network byte order) encoding. + private static string AuxWithUf(DataFormat dataFormat, TypeCode typeCode = TypeCode.Object) => + $$"""{"uf":{{new Flags { DataFormat = dataFormat, TypeCode = typeCode }.ToUInt32()}}}"""; + + #region ParseStagedUserFlags + + [Fact] + public void ParseStagedUserFlags_JsonUf_ReturnsStagedFlags() + { + var flags = DocumentRepository.ParseStagedUserFlags(XattrsWithAux(AuxWithUf(DataFormat.Json))); + + Assert.Equal(DataFormat.Json, flags.DataFormat); + Assert.Equal(TypeCode.Object, flags.TypeCode); + } + + [Fact] + public void ParseStagedUserFlags_BinaryUf_ReturnsBinaryFlags() + { + var flags = DocumentRepository.ParseStagedUserFlags(XattrsWithAux(AuxWithUf(DataFormat.Binary))); + + Assert.Equal(DataFormat.Binary, flags.DataFormat); + } + + [Fact] + public void ParseStagedUserFlags_NoAux_FallsBackToJsonCommonFlags() + { + var flags = DocumentRepository.ParseStagedUserFlags(XattrsWithAux(null)); + + Assert.Equal(DataFormat.Json, flags.DataFormat); + Assert.Equal(TypeCode.Object, flags.TypeCode); + } + + [Fact] + public void ParseStagedUserFlags_AuxWithoutUf_FallsBackToJsonCommonFlags() + { + var flags = DocumentRepository.ParseStagedUserFlags(XattrsWithAux("""{"docexpiry":123}""")); + + Assert.Equal(DataFormat.Json, flags.DataFormat); + } + + [Fact] + public void ParseStagedUserFlags_NullXattrs_FallsBackToJsonCommonFlags() + { + var flags = DocumentRepository.ParseStagedUserFlags(null); + + Assert.Equal(DataFormat.Json, flags.DataFormat); + } + + #endregion + + #region End-to-end via LookupDocumentAsync + + private static Mock BuildLookupResult(Flags bodyFlags, TransactionXattrs txnXattrs) + { + var specs = new List + { + new() { Bytes = JsonBytes }, // 0 txn xattrs + new() { Bytes = JsonBytes }, // 1 $document meta + new() { Bytes = JsonBytes }, // 2 staged JSON data + new() { Bytes = BinaryBytes }, // 3 staged binary data + new() { Bytes = JsonBytes }, // 4 full document body + }; + + var mock = new Mock(); + mock.Setup(r => r.Specs).Returns(specs); + mock.Setup(r => r.Flags).Returns(bodyFlags); + mock.Setup(r => r.ContentAs(0)).Returns(txnXattrs); + mock.Setup(r => r.Exists(0)).Returns(true); + mock.Setup(r => r.Exists(2)).Returns(true); // JSON staged + mock.Setup(r => r.Exists(3)).Returns(false); + mock.Setup(r => r.Exists(4)).Returns(true); + mock.Setup(r => r.IsDeleted).Returns(false); + mock.Setup(r => r.Cas).Returns(0UL); + return mock; + } + + private static ICouchbaseCollection BuildCollection(Mock lookupResult) + { + var mockBucket = new Mock(); + mockBucket.Setup(b => b.Name).Returns("b"); + var mockScope = new Mock(); + mockScope.Setup(s => s.Name).Returns("s"); + mockScope.Setup(s => s.Bucket).Returns(mockBucket.Object); + var mockCollection = new Mock(); + mockCollection.Setup(c => c.Name).Returns("c"); + mockCollection.Setup(c => c.Scope).Returns(mockScope.Object); + mockCollection + .Setup(c => c.LookupInAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(lookupResult.Object); + return mockCollection.Object; + } + + [Fact] + public async Task StagedContent_UsesUf_NotBodyFlags() + { + // Body flags deliberately differ from the staged uf to prove the source. + var bodyFlags = new Flags { DataFormat = DataFormat.String }; + var mock = BuildLookupResult(bodyFlags, XattrsWithAux(AuxWithUf(DataFormat.Json))); + + var result = await DocumentRepository.LookupDocumentAsync( + BuildCollection(mock), "doc-id", keyValueTimeout: null, + defaultJsonTranscoder: new JsonTranscoder(SystemTextJsonSerializer.Create())); + + Assert.NotNull(result.StagedContent); + Assert.Equal(DataFormat.Json, result.StagedContent!.Flags.DataFormat); + Assert.Equal(TypeCode.Object, result.StagedContent.Flags.TypeCode); + } + + [Fact] + public async Task UnstagedContent_StillUsesBodyFlags() + { + var bodyFlags = new Flags { DataFormat = DataFormat.Json, TypeCode = TypeCode.String }; + var mock = BuildLookupResult(bodyFlags, XattrsWithAux(AuxWithUf(DataFormat.Json))); + + var result = await DocumentRepository.LookupDocumentAsync( + BuildCollection(mock), "doc-id", keyValueTimeout: null, + defaultJsonTranscoder: new JsonTranscoder(SystemTextJsonSerializer.Create())); + + Assert.NotNull(result.UnstagedContent); + // Unstaged (pre-transaction) content keeps the live body flags, not the staged uf. + Assert.Equal(TypeCode.String, result.UnstagedContent!.Flags.TypeCode); + } + + [Fact] + public async Task StagedContent_NoUf_FallsBackToJson() + { + var bodyFlags = new Flags { DataFormat = DataFormat.String }; + var mock = BuildLookupResult(bodyFlags, XattrsWithAux(null)); + + var result = await DocumentRepository.LookupDocumentAsync( + BuildCollection(mock), "doc-id", keyValueTimeout: null, + defaultJsonTranscoder: new JsonTranscoder(SystemTextJsonSerializer.Create())); + + Assert.NotNull(result.StagedContent); + Assert.Equal(DataFormat.Json, result.StagedContent!.Flags.DataFormat); + } + + #endregion +}