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(() => 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 CreateHostAsync( InMemoryWebDavHandler handler, string basePath = "/dysync-test", IReadOnlyList 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); } }