feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user