Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion bindings/dotnet/OpenDAL.Tests/Behavior/CopyBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* under the License.
*/

using OpenDAL.Options;

namespace OpenDAL.Tests;

[Collection("BehaviorOperator")]
Expand Down Expand Up @@ -65,7 +67,7 @@ public async Task CopyBehavior_CreatesTargetWithSameContentAsync()
var content = RandomBytes(256);

await Op.WriteAsync(sourcePath, content, cancellationToken: CT);
var meta = await Op.CopyAsync(sourcePath, targetPath, CT);
var meta = await Op.CopyAsync(sourcePath, targetPath, cancellationToken: CT);

if (meta.IsFile)
{
Expand All @@ -74,4 +76,65 @@ public async Task CopyBehavior_CreatesTargetWithSameContentAsync()

Assert.Equal(content, await Op.ReadAsync(targetPath, cancellationToken: CT));
}

[Fact]
public void CopyBehavior_IfNotExists_CopiesToNewPath()
{
if (!Supports(c => c.Copy && c.Read && c.Write && c.CopyWithIfNotExists))
{
return;
}

var sourcePath = NewPath("copy-if-not-exists-source");
var targetPath = NewPath("copy-if-not-exists-target");
var content = RandomBytes(256);

Op.Write(sourcePath, content);
Op.Copy(sourcePath, targetPath, new CopyOptions { IfNotExists = true });

Assert.Equal(content, Op.Read(targetPath));
}

[Fact]
public async Task CopyBehavior_IfNotExists_RejectsExistingTargetAsync()
{
if (!Supports(c => c.Copy && c.Read && c.Write && c.CopyWithIfNotExists))
{
return;
}

var sourcePath = NewPath("copy-if-not-exists-existing-source");
var targetPath = NewPath("copy-if-not-exists-existing-target");
var targetContent = RandomBytes(128);

await Op.WriteAsync(sourcePath, RandomBytes(256), cancellationToken: CT);
await Op.WriteAsync(targetPath, targetContent, cancellationToken: CT);

var ex = await Assert.ThrowsAsync<OpenDALException>(
() => Op.CopyAsync(sourcePath, targetPath, new CopyOptions { IfNotExists = true }, CT));
Assert.Equal(ErrorCode.ConditionNotMatch, ex.Code);
Assert.Equal(targetContent, await Op.ReadAsync(targetPath, cancellationToken: CT));
}

[Fact]
public async Task CopyBehavior_IfNotChanged_RejectsStaleTargetMetadataAsync()
{
if (!Supports(c => c.Copy && c.Write && c.Stat && (c.CopyWithIfMatch || c.CopyWithIfVersionMatch)))
{
return;
}

var sourcePath = NewPath("copy-if-not-changed-source");
var targetPath = NewPath("copy-if-not-changed-target");

await Op.WriteAsync(sourcePath, RandomBytes(256), cancellationToken: CT);
await Op.WriteAsync(targetPath, RandomBytes(128), cancellationToken: CT);
var expected = await Op.StatAsync(targetPath, cancellationToken: CT);

await Op.CopyAsync(sourcePath, targetPath, new CopyOptions { IfNotChanged = expected }, CT);

var ex = await Assert.ThrowsAsync<OpenDALException>(
() => Op.CopyAsync(sourcePath, targetPath, new CopyOptions { IfNotChanged = expected }, CT));
Assert.Equal(ErrorCode.ConditionNotMatch, ex.Code);
}
}
24 changes: 24 additions & 0 deletions bindings/dotnet/OpenDAL.Tests/Behavior/DeleteBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,28 @@ public async Task DeleteBehavior_WithIfMatch_DoesNotRemoveObjectIfNotMatchedAsyn
var stat = await Op.StatAsync(path, cancellationToken: CT);
Assert.Equal(etag, stat.ETag);
}

[Fact]
public async Task DeleteBehavior_IfNotChanged_RejectsStaleMetadataAsync()
{
if (!Supports(c => c.Write && c.Stat && (c.DeleteWithIfMatch || c.DeleteWithIfVersionMatch)))
{
return;
}

var path = NewPath("delete-if-not-changed");
await Op.WriteAsync(path, RandomBytes(16), cancellationToken: CT);
var stale = await Op.StatAsync(path, cancellationToken: CT);
await Op.WriteAsync(path, RandomBytes(32), cancellationToken: CT);

var ex = await Assert.ThrowsAsync<OpenDALException>(
() => Op.DeleteAsync(path, new DeleteOptions { IfNotChanged = stale }, CT));
Assert.Equal(ErrorCode.ConditionNotMatch, ex.Code);

var current = await Op.StatAsync(path, cancellationToken: CT);
await Op.DeleteAsync(path, new DeleteOptions { IfNotChanged = current }, CT);

var missing = await Assert.ThrowsAsync<OpenDALException>(() => Op.StatAsync(path, cancellationToken: CT));
Assert.True(IsMissingError(missing));
}
}
47 changes: 47 additions & 0 deletions bindings/dotnet/OpenDAL.Tests/Behavior/ReadBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,51 @@ public async Task ReadBehavior_ConsumeCallback_RejectsNullConsumer()
await Assert.ThrowsAsync<ArgumentNullException>(() => Op.ReadAsync(
path, (Func<ReadOnlySequence<byte>, byte[]>)null!, cancellationToken: CT));
}

[Fact]
public async Task ReadBehavior_WithSuffixLength_ReturnsTailAsync()
{
if (!Supports(c => c.Read && c.Write && c.ReadWithSuffix))
{
return;
}

var path = NewPath("read-suffix");
var content = RandomBytes(1024);

await Op.WriteAsync(path, content, cancellationToken: CT);
var tail = await Op.ReadAsync(path, new ReadOptions { SuffixLength = 257 }, CT);

Assert.Equal(content.AsSpan(content.Length - 257).ToArray(), tail);
}

[Fact]
public void ReadBehavior_WithSuffixLengthLargerThanFile_ReturnsWholeFile()
{
if (!Supports(c => c.Read && c.Write && c.ReadWithSuffix))
{
return;
}

var path = NewPath("read-suffix-larger");
var content = RandomBytes(1024);

Op.Write(path, content);
var tail = Op.Read(path, new ReadOptions { SuffixLength = content.Length * 2 });

Assert.Equal(content, tail);
}

[Fact]
public void ReadBehavior_WithSuffixLengthAndOffset_RejectsOptions()
{
if (!Supports(c => c.Read))
{
return;
}

var ex = Assert.Throws<ArgumentException>(
() => Op.Read(NewPath("read-suffix-offset"), new ReadOptions { Offset = 1, SuffixLength = 1 }));
Assert.Equal("SuffixLength", ex.ParamName);
}
}
47 changes: 46 additions & 1 deletion bindings/dotnet/OpenDAL.Tests/Behavior/RenameBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* under the License.
*/

using OpenDAL.Options;

namespace OpenDAL.Tests;

[Collection("BehaviorOperator")]
Expand Down Expand Up @@ -62,10 +64,53 @@ public async Task RenameBehavior_MovesContentToTargetPathAsync()
var content = RandomBytes(111);

await Op.WriteAsync(sourcePath, content, cancellationToken: CT);
await Op.RenameAsync(sourcePath, targetPath, CT);
await Op.RenameAsync(sourcePath, targetPath, cancellationToken: CT);

Assert.Equal(content, await Op.ReadAsync(targetPath, cancellationToken: CT));
var ex = await Assert.ThrowsAsync<OpenDALException>(() => Op.ReadAsync(sourcePath, cancellationToken: CT));
Assert.True(IsMissingError(ex));
}

[Fact]
public void RenameBehavior_IfNotExists_MovesToNewPath()
{
if (!Supports(c => c.Rename && c.Read && c.Write && c.RenameWithIfNotExists))
{
return;
}

var sourcePath = NewPath("rename-if-not-exists-source");
var targetPath = NewPath("rename-if-not-exists-target");
var content = RandomBytes(111);

Op.Write(sourcePath, content);
Op.Rename(sourcePath, targetPath, new RenameOptions { IfNotExists = true });

Assert.Equal(content, Op.Read(targetPath));
var ex = Assert.Throws<OpenDALException>(() => Op.Read(sourcePath));
Assert.True(IsMissingError(ex));
}

[Fact]
public async Task RenameBehavior_IfNotExists_RejectsExistingTargetAsync()
{
if (!Supports(c => c.Rename && c.Read && c.Write && c.RenameWithIfNotExists))
{
return;
}

var sourcePath = NewPath("rename-if-not-exists-existing-source");
var targetPath = NewPath("rename-if-not-exists-existing-target");
var sourceContent = RandomBytes(111);
var targetContent = RandomBytes(222);

await Op.WriteAsync(sourcePath, sourceContent, cancellationToken: CT);
await Op.WriteAsync(targetPath, targetContent, cancellationToken: CT);

var ex = await Assert.ThrowsAsync<OpenDALException>(
() => Op.RenameAsync(sourcePath, targetPath, new RenameOptions { IfNotExists = true }, CT));
Assert.Equal(ErrorCode.ConditionNotMatch, ex.Code);
Assert.Equal(sourceContent, await Op.ReadAsync(sourcePath, cancellationToken: CT));
Assert.Equal(targetContent, await Op.ReadAsync(targetPath, cancellationToken: CT));
}
}
19 changes: 19 additions & 0 deletions bindings/dotnet/OpenDAL.Tests/Behavior/StreamBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,23 @@ public void StreamBehavior_OpenReadStream_VersionUnsupported_SurfacesUnsupported

Assert.Equal(ErrorCode.Unsupported, ex.Code);
}

[Fact]
public void StreamBehavior_OpenReadStream_WithSuffixLength_ReturnsTail()
{
if (!Supports(c => c.Read && c.Write && c.ReadWithSuffix))
{
return;
}

var path = NewPath("stream-suffix");
var content = RandomBytes(1024);
Op.Write(path, content);

using var input = Op.OpenReadStream(path, new ReadOptions { SuffixLength = 100 });
using var output = new MemoryStream();
input.CopyTo(output);

Assert.Equal(content.AsSpan(content.Length - 100).ToArray(), output.ToArray());
}
}
74 changes: 74 additions & 0 deletions bindings/dotnet/OpenDAL.Tests/Behavior/WriteBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,78 @@ public void WriteBehavior_WithUserMetadata_RoundtripsThroughStat()
Assert.NotNull(meta.UserMetadata);
Assert.Equal("everywhere", meta.UserMetadata["location"]);
}

[Fact]
public async Task WriteBehavior_IfNotChanged_RejectsStaleMetadataAsync()
{
if (!Supports(c => c.Write && c.Stat && (c.WriteWithIfMatch || c.WriteWithIfVersionMatch)))
{
return;
}

var path = NewPath("write-if-not-changed");
await Op.WriteAsync(path, RandomBytes(64), cancellationToken: CT);
var expected = await Op.StatAsync(path, cancellationToken: CT);

var replacement = RandomBytes(96);
await Op.WriteAsync(path, replacement, new WriteOptions { IfNotChanged = expected }, CT);

var ex = await Assert.ThrowsAsync<OpenDALException>(
() => Op.WriteAsync(path, RandomBytes(32), new WriteOptions { IfNotChanged = expected }, CT));
Assert.Equal(ErrorCode.ConditionNotMatch, ex.Code);
Assert.Equal(replacement, await Op.ReadAsync(path, cancellationToken: CT));
}

[Fact]
public async Task WriteBehavior_OptionsLargerThanStackBuffer_RoundTripAsync()
{
if (!Supports(c => c.Write && c.Stat && c.WriteWithContentType))
{
return;
}

// Well past the stack buffer, so the options go through native memory
// instead. ASCII only, since it travels as an HTTP header.
var contentType = $"text/x-{new string('a', 1024)}";
var syncPath = NewPath("write-large-options");
var asyncPath = NewPath("write-large-options-async");

Op.Write(syncPath, new byte[] { 1 }, new WriteOptions { ContentType = contentType });
await Op.WriteAsync(asyncPath, new byte[] { 1 }, new WriteOptions { ContentType = contentType }, CT);

Assert.Equal(contentType, (await Op.StatAsync(syncPath, cancellationToken: CT)).ContentType);
Assert.Equal(contentType, (await Op.StatAsync(asyncPath, cancellationToken: CT)).ContentType);
}

[Fact]
public async Task WriteBehavior_ConcurrentWrites_KeepEachCallsOptionsAsync()
{
// The race this guards against is in the binding, not the service, and
// it needs many concurrent calls to show, so only memory runs it.
if (!IsService("memory") || !Supports(c => c.Write && c.Stat && c.WriteWithContentType))
{
return;
}

// Each call writes its options into a buffer of its own, so
// concurrent calls must never see each other's options.
var writes = Enumerable.Range(0, 1000).Select(i => Task.Run(async () =>
{
var path = NewPath($"write-concurrent-{i}");
var options = new WriteOptions { ContentType = $"text/x-{i}-{new string('a', i % 64)}" };
if (i % 2 == 0)
{
Op.Write(path, new byte[] { 1 }, options);
}
else
{
await Op.WriteAsync(path, new byte[] { 1 }, options, CT);
}

var meta = await Op.StatAsync(path, cancellationToken: CT);
Assert.Equal(options.ContentType, meta.ContentType);
}, CT));

await Task.WhenAll(writes);
}
}
44 changes: 43 additions & 1 deletion bindings/dotnet/OpenDAL.Tests/ConstructorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void Constructor_OptionValueIsNull_ThrowsConfigInvalid()
var ex = Assert.Throws<OpenDALException>(() => new Operator("memory", options));

Assert.Equal(ErrorCode.ConfigInvalid, ex.Code);
Assert.Contains("value at index 0", ex.Message);
Assert.Contains("value of option root is null", ex.Message);
}

[Fact]
Expand Down Expand Up @@ -99,4 +99,46 @@ public void Constructor_ServiceConfigProvided_ConstructsOperator()

Assert.NotEqual(IntPtr.Zero, op.DangerousGetHandle());
}

[Fact]
public void Constructor_OptionsDictionaryCallsBackIntoOpenDAL_KeepsOptionsIntact()
{
using var other = new Operator("memory");
other.Write("file", new byte[] { 1, 2, 3 });
var options = new ReentrantDictionary(other, new Dictionary<string, string> { ["root"] = "/reentrant/" });

using var op = new Operator("memory", options);

Assert.Equal("/reentrant/", op.Info.Root);
}

/// <summary>
/// Serializes other options on the same thread while it is being enumerated.
/// </summary>
private sealed class ReentrantDictionary(Operator other, Dictionary<string, string> inner)
: IReadOnlyDictionary<string, string>
{
public string this[string key] => inner[key];

public IEnumerable<string> Keys => inner.Keys;

public IEnumerable<string> Values => inner.Values;

public int Count => inner.Count;

public bool ContainsKey(string key) => inner.ContainsKey(key);

public bool TryGetValue(string key, out string value) => inner.TryGetValue(key, out value!);

public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
foreach (var entry in inner)
{
other.Read("file", new OpenDAL.Options.ReadOptions { Offset = 1, Length = 1 });
yield return entry;
}
}

System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}
Loading
Loading