255 lines
12 KiB
C#
255 lines
12 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using dy.net.model.dto;
|
|
using dy.net.Tests.TestInfrastructure;
|
|
|
|
namespace dy.net.Tests;
|
|
|
|
public class WebDavMediaStorageTests
|
|
{
|
|
private const string UserName = "test-user";
|
|
private const string Password = "test-password";
|
|
|
|
[Fact]
|
|
public async Task WriteReadAndDelete_UseBasicAuthEncodedPathsAndAtomicMove()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password);
|
|
using var host = await CreateHostAsync(handler, "/媒体 库");
|
|
var path = "/作者 名/视频 #1.mp4";
|
|
var bytes = Encoding.UTF8.GetBytes("abcdefghij");
|
|
|
|
await using (var source = new MemoryStream(bytes, false))
|
|
await host.Storage.WriteAsync(path, source, bytes.Length, "video/mp4");
|
|
|
|
const string expectedServerPath = "/dav/媒体 库/作者 名/视频 #1.mp4";
|
|
Assert.True(await host.Storage.ExistsAsync(path));
|
|
Assert.Equal(bytes.Length, await host.Storage.GetLengthAsync(path));
|
|
Assert.Equal(bytes, handler.Files[expectedServerPath]);
|
|
Assert.DoesNotContain(handler.Files.Keys, x => x.Contains(".part-", StringComparison.Ordinal));
|
|
|
|
var put = Assert.Single(handler.Requests, x => x.Method == "PUT");
|
|
Assert.Contains(".part-", put.DecodedPath);
|
|
Assert.Contains("%E5%AA%92%E4%BD%93%20%E5%BA%93", put.RawUri);
|
|
Assert.Contains("%23", put.RawUri);
|
|
var move = Assert.Single(handler.Requests, x => x.Method == "MOVE");
|
|
Assert.EndsWith("/视频 #1.mp4", Uri.UnescapeDataString(new Uri(move.Destination).AbsolutePath));
|
|
Assert.All(handler.Requests, request => Assert.Equal(handler.ExpectedAuthorization, request.Authorization));
|
|
|
|
await using (var range = await host.Storage.OpenReadAsync(path, 2, 4))
|
|
{
|
|
Assert.Equal(206, range.StatusCode);
|
|
Assert.Equal("bytes 2-4/10", range.ContentRange);
|
|
using var reader = new StreamReader(range.Stream, Encoding.UTF8);
|
|
Assert.Equal("cde", await reader.ReadToEndAsync());
|
|
}
|
|
|
|
await host.Storage.DeleteAsync(path);
|
|
Assert.False(await host.Storage.ExistsAsync(path));
|
|
Assert.DoesNotContain(expectedServerPath, handler.Files.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenMoveFails_CleansTemporaryUpload()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password) { FailNextMove = true };
|
|
using var host = await CreateHostAsync(handler);
|
|
var bytes = Encoding.UTF8.GetBytes("upload that must be cleaned");
|
|
|
|
await using var source = new MemoryStream(bytes, false);
|
|
await Assert.ThrowsAsync<HttpRequestException>(() =>
|
|
host.Storage.WriteAsync("/failed/video.mp4", source, bytes.Length, "video/mp4"));
|
|
|
|
Assert.Empty(handler.Files);
|
|
Assert.Contains(handler.Requests, x => x.Method == "DELETE" && x.DecodedPath.Contains(".part-", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenExistingDirectoriesReturnConflict_VerifiesAndReusesCollections()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password)
|
|
{
|
|
ExistingDirectoryStatusCode = HttpStatusCode.Conflict
|
|
};
|
|
using var host = await CreateHostAsync(handler);
|
|
var first = Encoding.UTF8.GetBytes("first object");
|
|
var second = Encoding.UTF8.GetBytes("second object");
|
|
|
|
await using (var source = new MemoryStream(first, false))
|
|
await host.Storage.WriteAsync("/same-parent/first.mp4", source, first.Length, "video/mp4");
|
|
await using (var source = new MemoryStream(second, false))
|
|
await host.Storage.WriteAsync("/same-parent/second.mp4", source, second.Length, "video/mp4");
|
|
|
|
Assert.Equal(first, handler.Files["/dav/dysync-test/same-parent/first.mp4"]);
|
|
Assert.Equal(second, handler.Files["/dav/dysync-test/same-parent/second.mp4"]);
|
|
Assert.Contains(handler.Requests, request => request.Method == "PROPFIND"
|
|
&& request.DecodedPath == "/dav/dysync-test/same-parent");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenMovedMetadataIsStale_UsesRangeReadToVerifyObject()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password)
|
|
{
|
|
StaleMetadataReadsAfterMove = 1
|
|
};
|
|
using var host = await CreateHostAsync(handler);
|
|
var bytes = Encoding.UTF8.GetBytes("moved object is already readable");
|
|
|
|
await using (var source = new MemoryStream(bytes, false))
|
|
await host.Storage.WriteAsync("/eventual/video.mp4", source, bytes.Length, "video/mp4");
|
|
|
|
Assert.Equal(bytes.Length, await host.Storage.GetLengthAsync("/eventual/video.mp4"));
|
|
Assert.Contains(handler.Requests, request => request.Method == "GET" && request.Range == "bytes=0-0");
|
|
Assert.DoesNotContain(handler.Files.Keys, path => path.Contains(".part-", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenMovedObjectIsBrieflyInvisible_RetriesVerification()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password)
|
|
{
|
|
// The first HEAD and ranged GET both observe stale state. The next delayed
|
|
// verification sees the object and must complete without uploading again.
|
|
StaleAllReadsAfterMove = 2
|
|
};
|
|
using var host = await CreateHostAsync(handler);
|
|
var bytes = Encoding.UTF8.GetBytes("eventually visible object");
|
|
|
|
await using (var source = new MemoryStream(bytes, false))
|
|
await host.Storage.WriteAsync("/eventual/retry.mp4", source, bytes.Length, "video/mp4");
|
|
|
|
Assert.Equal(bytes, handler.Files["/dav/dysync-test/eventual/retry.mp4"]);
|
|
Assert.Single(handler.Requests, request => request.Method == "PUT");
|
|
Assert.Single(handler.Requests, request => request.Method == "MOVE");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenMoveVisibilityExceedsOldRetryWindow_UsesExtendedVerification()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password)
|
|
{
|
|
// Six HEAD + Range verification rounds all see stale state. The seventh
|
|
// round represents visibility beyond the 0.2.12 retry window.
|
|
StaleAllReadsAfterMove = 12
|
|
};
|
|
using var host = await CreateHostAsync(handler, finalVisibilityRetryDelays:
|
|
Enumerable.Repeat(TimeSpan.Zero, 7).ToArray());
|
|
var bytes = Encoding.UTF8.GetBytes("visible after the old retry window");
|
|
|
|
await using (var source = new MemoryStream(bytes, false))
|
|
await host.Storage.WriteAsync("/eventual/extended.mp4", source, bytes.Length, "video/mp4");
|
|
|
|
Assert.Equal(bytes, handler.Files["/dav/dysync-test/eventual/extended.mp4"]);
|
|
Assert.Single(handler.Requests, request => request.Method == "PUT");
|
|
Assert.Single(handler.Requests, request => request.Method == "MOVE");
|
|
Assert.True(handler.Requests.Count(request => request.Method is "HEAD" or "GET") >= 13);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_WhenOverwriteBrieflyReturnsOldLength_WaitsForNewObject()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password);
|
|
using var host = await CreateHostAsync(handler, finalVisibilityRetryDelays:
|
|
Enumerable.Repeat(TimeSpan.Zero, 3).ToArray());
|
|
var path = "/eventual/overwrite.mp4";
|
|
var oldBytes = Encoding.UTF8.GetBytes("old-content");
|
|
await using (var oldSource = new MemoryStream(oldBytes, false))
|
|
await host.Storage.WriteAsync(path, oldSource, oldBytes.Length, "video/mp4");
|
|
|
|
handler.StaleLengthReadsAfterMove = 4;
|
|
var newBytes = Encoding.UTF8.GetBytes("new-content-with-a-different-length");
|
|
await using (var newSource = new MemoryStream(newBytes, false))
|
|
await host.Storage.WriteAsync(path, newSource, newBytes.Length, "video/mp4");
|
|
|
|
Assert.Equal(newBytes, handler.Files["/dav/dysync-test/eventual/overwrite.mp4"]);
|
|
Assert.Contains(handler.Requests, request => request.Method == "GET"
|
|
&& request.Range == "bytes=0-0"
|
|
&& request.CacheControl?.Contains("no-cache", StringComparison.OrdinalIgnoreCase) == true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BoundStorage_KeepsOneTaskOnItsOriginalTargetAfterGlobalConfigChanges()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password);
|
|
using var host = await CreateHostAsync(handler, "/original-target");
|
|
var bound = host.Storage.Bind(host.Settings);
|
|
var changed = await host.SettingsService.BuildCandidateAsync(new WebDavTestRequest
|
|
{
|
|
Endpoint = host.Settings.Endpoint,
|
|
BasePath = "/changed-target",
|
|
UserName = UserName,
|
|
Password = Password
|
|
});
|
|
await host.SettingsService.SaveAsync(changed, true, "changed");
|
|
var bytes = Encoding.UTF8.GetBytes("task snapshot");
|
|
|
|
await using (var source = new MemoryStream(bytes, false))
|
|
await bound.WriteAsync("/video.mp4", source, bytes.Length, "video/mp4");
|
|
|
|
Assert.Contains("/dav/original-target/video.mp4", handler.Files.Keys);
|
|
Assert.DoesNotContain("/dav/changed-target/video.mp4", handler.Files.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_ExercisesRequiredWebDavCapabilitiesAndCleansProbeData()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password);
|
|
using var host = await CreateHostAsync(handler);
|
|
|
|
var result = await host.Storage.ProbeAsync(host.Settings);
|
|
|
|
Assert.True(result.Success, result.Message);
|
|
Assert.Empty(handler.Files);
|
|
Assert.Contains(handler.Requests, x => x.Method == "MKCOL");
|
|
Assert.Contains(handler.Requests, x => x.Method == "PUT");
|
|
Assert.Contains(handler.Requests, x => x.Method == "HEAD");
|
|
Assert.Contains(handler.Requests, x => x.Method == "MOVE");
|
|
Assert.Contains(handler.Requests, x => x.Method == "GET" && x.Range == "bytes=0-0");
|
|
Assert.Contains(handler.Requests, x => x.Method == "DELETE");
|
|
Assert.All(handler.Requests, request => Assert.Equal(handler.ExpectedAuthorization, request.Authorization));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_RejectsServerThatIgnoresRangeRequests()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password) { SupportsRange = false };
|
|
using var host = await CreateHostAsync(handler);
|
|
|
|
var result = await host.Storage.ProbeAsync(host.Settings);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("206", result.Message);
|
|
Assert.Empty(handler.Files);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProbeAsync_RejectsPartialResponseWithoutContentRange()
|
|
{
|
|
var handler = new InMemoryWebDavHandler(UserName, Password) { IncludeContentRange = false };
|
|
using var host = await CreateHostAsync(handler);
|
|
|
|
var result = await host.Storage.ProbeAsync(host.Settings);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Content-Range", result.Message);
|
|
Assert.Empty(handler.Files);
|
|
}
|
|
|
|
private static async Task<WebDavTestHost> CreateHostAsync(
|
|
InMemoryWebDavHandler handler,
|
|
string basePath = "/dysync-test",
|
|
IReadOnlyList<TimeSpan> finalVisibilityRetryDelays = null)
|
|
{
|
|
var factory = new StubHttpClientFactory(handler);
|
|
return await WebDavTestHost.CreateAsync(factory, factory, new WebDavTestRequest
|
|
{
|
|
Endpoint = "https://dav.example.test/dav",
|
|
BasePath = basePath,
|
|
UserName = UserName,
|
|
Password = Password
|
|
},
|
|
finalVisibilityRetryDelays == null ? null : new[] { TimeSpan.Zero },
|
|
finalVisibilityRetryDelays);
|
|
}
|
|
}
|